Implement ignore rule caching

This commit is contained in:
Shadowghost
2026-02-01 23:07:01 +01:00
parent 622947e374
commit d20c775daf
8 changed files with 663 additions and 42 deletions

View File

@@ -536,6 +536,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
serviceCollection.AddSingleton<DotIgnoreIgnoreRule>();
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();

View File

@@ -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;
}

View File

@@ -1,6 +1,7 @@
using System;
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 +16,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 +53,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);
if (ignoreFile is null)
var ignoreFilePath = FindIgnoreFileCached(searchDirectory);
if (ignoreFilePath 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(ignoreFilePath);
if (parsedEntry is null)
{
// File was deleted after we cached the path - clear the directory cache entry and return false
_directoryCache.TryRemove(searchDirectory, out _);
return false;
}
// Empty file means ignore everything
if (parsedEntry.IsEmpty)
{
// Ignore directory if we just have the file
return true;
}
var content = GetFileContent(ignoreFile);
return string.IsNullOrWhiteSpace(content)
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
}
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 +130,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 +143,194 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
return ignore.IsIgnored(pathToCheck);
}
private static string GetFileContent(FileInfo ignoreFile)
private string? FindIgnoreFileCached(string directory)
{
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
return ignoreFile.Exists
? File.ReadAllText(ignoreFile.FullName)
: string.Empty;
// Check if we have a cached result for this directory
if (_directoryCache.TryGet(directory, out var cached))
{
return cached.IgnoreFilePath;
}
// Walk up the directory tree to find .ignore file
var current = directory;
var checkedDirs = new System.Collections.Generic.List<string> { directory };
while (!string.IsNullOrEmpty(current))
{
// Check if this intermediate directory is cached
if (current != directory && _directoryCache.TryGet(current, out var parentCached))
{
// Cache the result for all directories we checked
var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFilePath);
foreach (var dir in checkedDirs)
{
_directoryCache.AddOrUpdate(dir, entry);
}
return parentCached.IgnoreFilePath;
}
var ignorePath = Path.Join(current, ".ignore");
if (File.Exists(ignorePath))
{
// Cache for all directories we checked
var entry = new IgnoreFileCacheEntry(ignorePath);
foreach (var dir in checkedDirs)
{
_directoryCache.AddOrUpdate(dir, entry);
}
return ignorePath;
}
var parent = Path.GetDirectoryName(current);
if (parent == current || string.IsNullOrEmpty(parent))
{
break;
}
current = parent;
checkedDirs.Add(current);
}
// No .ignore file found - cache null result for all directories
var nullEntry = new IgnoreFileCacheEntry(null);
foreach (var dir in checkedDirs)
{
_directoryCache.AddOrUpdate(dir, nullEntry);
}
return null;
}
private ParsedIgnoreCacheEntry? GetParsedRules(string ignoreFilePath)
{
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(ignoreFilePath);
if (!fileInfo.Exists)
{
_rulesCache.TryRemove(ignoreFilePath, out _);
return null;
}
}
catch
{
_rulesCache.TryRemove(ignoreFilePath, out _);
return null;
}
var lastModified = fileInfo.LastWriteTimeUtc;
var fileLength = fileInfo.Length;
// Check cache
if (_rulesCache.TryGet(ignoreFilePath, out var cached))
{
if (cached.FileLastModified == lastModified && cached.FileLength == fileLength)
{
return cached;
}
// Stale - need to reparse
_rulesCache.TryRemove(ignoreFilePath, out _);
}
// Parse the file
var parsedEntry = ParseIgnoreFile(fileInfo, lastModified, fileLength);
_rulesCache.AddOrUpdate(ignoreFilePath, parsedEntry);
return parsedEntry;
}
private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength)
{
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? IgnoreFilePath);
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; }
}
}

View File

@@ -84,6 +84,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.
@@ -125,6 +126,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,
@@ -146,7 +148,8 @@ namespace Emby.Server.Implementations.Library
NamingOptions namingOptions,
IDirectoryService directoryService,
IPeopleRepository peopleRepository,
IPathManager pathManager)
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -171,6 +174,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;
@@ -1303,6 +1307,7 @@ namespace Emby.Server.Implementations.Library
public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken)
{
IsScanRunning = true;
ClearIgnoreRuleCache();
LibraryMonitor.Stop();
try
@@ -1311,6 +1316,7 @@ namespace Emby.Server.Implementations.Library
}
finally
{
ClearIgnoreRuleCache();
LibraryMonitor.Start();
IsScanRunning = false;
}
@@ -1318,6 +1324,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);
@@ -1360,6 +1367,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)
@@ -3161,7 +3176,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)

View File

@@ -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
{

View File

@@ -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>

View File

@@ -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();
}
}

View File

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