diff --git a/LICENSE b/LICENSE index 1adf46f..9ee123f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ ISC License -Copyright (c) 2025 Alan Barber +Copyright (c) 2026 Alan Barber Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/QUICKSTART.md b/QUICKSTART.md index d8476f9..2a647f7 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -127,6 +127,9 @@ BitCheck --file myfile.txt --info # Show file status BitCheck --list # Current directory BitCheck --list --recursive # All directories BitCheck --list --single-db # Single database mode + +# Ignore parity/temp files (via .bitcheckignore or inline patterns) +BitCheck --add --recursive --ignore-pattern *.par2 --ignore-pattern *.tmp ``` ## What Gets Created @@ -226,6 +229,7 @@ BitCheck --add --recursive 9. **Use `--delete` to clean up** - Remove obsolete entries from database without deleting actual files 10. **Use `--info` to check status** - See if a file is tracked and its database details 11. **Use `--list` to audit** - See all files currently tracked in the database +12. **Use `--ignore-pattern` or `.bitcheckignore`** - Skip parity files, temp files, or other clutter from tracking ## Help diff --git a/README.md b/README.md index a1aa3e7..29d3c5c 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,29 @@ This makes BitCheck practical for real-world use where files are frequently edit - `-d, --delete` - Delete a file record from the database (only valid with `--file`) - `-i, --info` - Show database information for a single file (only valid with `--file`) - `-l, --list` - List all files tracked in the database +- `--ignore-pattern ` - Glob pattern to ignore (repeatable); combines with any `.bitcheckignore` files found while scanning - `--help` - Show help information +## Ignoring Files + +BitCheck can skip files and directories that don't need integrity tracking, such as parity files or build artifacts. There are two ways to specify what to ignore: + +- **`.bitcheckignore` file** - Place a `.bitcheckignore` file in any directory to ignore matching entries in that directory (and, in `--recursive` mode, its subdirectories). One pattern per line. +- **`--ignore-pattern `** - Pass patterns directly on the command line (repeatable) to apply the same rules without a file. + +``` +# .bitcheckignore +*.par2 +*.tmp +!keep.tmp +``` + +- Blank lines and lines starting with `#` are ignored. +- Patterns support `*` (any characters) and `?` (single character) wildcards, and are matched against the file or directory **name only** (not the full path). +- A line starting with `!` re-includes a name that an earlier pattern excluded. +- In `--recursive` mode, a `.bitcheckignore` in a subdirectory is combined with its parent directories' rules, with the subdirectory's own rules taking precedence — so a nested `.bitcheckignore` can override an inherited ignore rule for files in that subdirectory. +- `--file` (single-file mode) always processes the file you specify, even if it matches an ignore pattern. + ## Usage Examples ### Monitor Your Files (First Time) diff --git a/src/BitCheck.Tests/ApplicationTests/IgnorePatternTests.cs b/src/BitCheck.Tests/ApplicationTests/IgnorePatternTests.cs new file mode 100644 index 0000000..e6d8184 --- /dev/null +++ b/src/BitCheck.Tests/ApplicationTests/IgnorePatternTests.cs @@ -0,0 +1,156 @@ +using BitCheck.Application; +using BitCheck.Database; + +namespace BitCheck.Tests.ApplicationTests +{ + [TestClass] + public class IgnorePatternTests : ApplicationTestBase + { + [TestMethod] + public void RootIgnoreFile_ExcludesMatchingFiles_FromAdd() + { + var keepFile = Path.Combine(_testDir, "keep.txt"); + var parityFile = Path.Combine(_testDir, "archive.par2"); + File.WriteAllText(keepFile, "keep"); + File.WriteAllText(parityFile, "parity"); + File.WriteAllLines(Path.Combine(_testDir, BitCheckConstants.IgnoreFileName), new[] { "*.par2" }); + + var options = new AppOptions( + Recursive: false, + Add: true, + Update: false, + Check: false, + Verbose: false, + Strict: false, + Timestamps: false, + SingleDatabase: false, + File: null, + Delete: false, + Info: false, + List: false); + + RunApp(options, _testDir); + + using var db = new DatabaseService(Path.Combine(_testDir, BitCheckConstants.DatabaseFileName)); + Assert.IsNotNull(db.GetFileEntry("keep.txt"), "Non-matching file should be tracked"); + Assert.IsNull(db.GetFileEntry("archive.par2"), "File matching ignore pattern should not be tracked"); + } + + [TestMethod] + public void IgnorePatternOption_ExcludesMatchingFiles_WithoutIgnoreFile() + { + var keepFile = Path.Combine(_testDir, "keep.txt"); + var parityFile = Path.Combine(_testDir, "archive.par2"); + File.WriteAllText(keepFile, "keep"); + File.WriteAllText(parityFile, "parity"); + + var options = new AppOptions( + Recursive: false, + Add: true, + Update: false, + Check: false, + Verbose: false, + Strict: false, + Timestamps: false, + SingleDatabase: false, + File: null, + Delete: false, + Info: false, + List: false, + IgnorePatterns: new[] { "*.par2" }); + + RunApp(options, _testDir); + + using var db = new DatabaseService(Path.Combine(_testDir, BitCheckConstants.DatabaseFileName)); + Assert.IsNotNull(db.GetFileEntry("keep.txt"), "Non-matching file should be tracked"); + Assert.IsNull(db.GetFileEntry("archive.par2"), "File matching --ignore-pattern should not be tracked"); + } + + [TestMethod] + public void NestedIgnoreFile_NegationOverridesParent_OnlyWithinSubdirectory() + { + var subDir = Path.Combine(_testDir, "sub"); + Directory.CreateDirectory(subDir); + + File.WriteAllText(Path.Combine(_testDir, "root.par2"), "root parity"); + File.WriteAllText(Path.Combine(subDir, "special.par2"), "special parity"); + File.WriteAllLines(Path.Combine(_testDir, BitCheckConstants.IgnoreFileName), new[] { "*.par2" }); + File.WriteAllLines(Path.Combine(subDir, BitCheckConstants.IgnoreFileName), new[] { "!special.par2" }); + + var options = new AppOptions( + Recursive: true, + Add: true, + Update: false, + Check: false, + Verbose: false, + Strict: false, + Timestamps: false, + SingleDatabase: true, + File: null, + Delete: false, + Info: false, + List: false); + + RunApp(options, _testDir); + + using var db = new DatabaseService(Path.Combine(_testDir, BitCheckConstants.DatabaseFileName)); + Assert.IsNull(db.GetFileEntry("root.par2"), "Root file matching root ignore pattern should not be tracked"); + Assert.IsNotNull(db.GetFileEntry(Path.Combine("sub", "special.par2")), "Nested negation should re-include the file within that subdirectory"); + } + + [TestMethod] + public void IgnoredDirectory_IsPrunedFromRecursion() + { + var ignoredDir = Path.Combine(_testDir, "vendor"); + Directory.CreateDirectory(ignoredDir); + File.WriteAllText(Path.Combine(ignoredDir, "inside.txt"), "inside"); + File.WriteAllLines(Path.Combine(_testDir, BitCheckConstants.IgnoreFileName), new[] { "vendor" }); + + var options = new AppOptions( + Recursive: true, + Add: true, + Update: false, + Check: false, + Verbose: false, + Strict: false, + Timestamps: false, + SingleDatabase: true, + File: null, + Delete: false, + Info: false, + List: false); + + RunApp(options, _testDir); + + using var db = new DatabaseService(Path.Combine(_testDir, BitCheckConstants.DatabaseFileName)); + Assert.IsNull(db.GetFileEntry(Path.Combine("vendor", "inside.txt")), "Files within an ignored directory should not be tracked"); + } + + [TestMethod] + public void SingleFileMode_BypassesIgnorePatterns() + { + var parityFile = Path.Combine(_testDir, "archive.par2"); + File.WriteAllText(parityFile, "parity"); + File.WriteAllLines(Path.Combine(_testDir, BitCheckConstants.IgnoreFileName), new[] { "*.par2" }); + + var options = new AppOptions( + Recursive: false, + Add: true, + Update: false, + Check: false, + Verbose: false, + Strict: false, + Timestamps: false, + SingleDatabase: false, + File: parityFile, + Delete: false, + Info: false, + List: false); + + RunApp(options, _testDir); + + using var db = new DatabaseService(Path.Combine(_testDir, BitCheckConstants.DatabaseFileName)); + Assert.IsNotNull(db.GetFileEntry("archive.par2"), "Explicitly specified file should be tracked even if it matches an ignore pattern"); + } + } +} diff --git a/src/BitCheck.Tests/IgnoreRuleSetTests.cs b/src/BitCheck.Tests/IgnoreRuleSetTests.cs new file mode 100644 index 0000000..5fd2327 --- /dev/null +++ b/src/BitCheck.Tests/IgnoreRuleSetTests.cs @@ -0,0 +1,110 @@ +using BitCheck.Application; + +namespace BitCheck.Tests +{ + [TestClass] + public class IgnoreRuleSetTests + { + [TestMethod] + public void BlankLinesAndComments_AreSkipped() + { + var rules = IgnoreRuleSet.Parse(new[] { "", " ", "# a comment", "*.par2" }); + + Assert.IsTrue(rules.IsIgnored("archive.par2")); + Assert.IsFalse(rules.IsIgnored("#not-a-comment")); + } + + [TestMethod] + public void WildcardPattern_MatchesBasename() + { + var rules = IgnoreRuleSet.Parse(new[] { "*.par2" }); + + Assert.IsTrue(rules.IsIgnored("data.par2")); + Assert.IsFalse(rules.IsIgnored("data.txt")); + } + + [TestMethod] + public void QuestionMarkPattern_MatchesSingleCharacter() + { + var rules = IgnoreRuleSet.Parse(new[] { "file?.log" }); + + Assert.IsTrue(rules.IsIgnored("file1.log")); + Assert.IsFalse(rules.IsIgnored("file12.log")); + } + + [TestMethod] + public void Negation_OverridesEarlierMatch_LastRuleWins() + { + var rules = IgnoreRuleSet.Parse(new[] { "*.par2", "!keep.par2" }); + + Assert.IsTrue(rules.IsIgnored("archive.par2")); + Assert.IsFalse(rules.IsIgnored("keep.par2")); + } + + [TestMethod] + public void LaterPattern_OverridesEarlierNegation() + { + var rules = IgnoreRuleSet.Parse(new[] { "!keep.par2", "keep.par2" }); + + Assert.IsTrue(rules.IsIgnored("keep.par2")); + } + + [TestMethod] + public void Combine_ChildRulesEvaluatedAfterParent_CanOverrideViaNegation() + { + var parent = IgnoreRuleSet.Parse(new[] { "*.par2" }); + var child = IgnoreRuleSet.Parse(new[] { "!special.par2" }); + + var combined = IgnoreRuleSet.Combine(parent, child); + + Assert.IsTrue(combined.IsIgnored("archive.par2")); + Assert.IsFalse(combined.IsIgnored("special.par2")); + } + + [TestMethod] + public void Combine_WithEmptyChild_ReturnsParentRules() + { + var parent = IgnoreRuleSet.Parse(new[] { "*.par2" }); + var child = IgnoreRuleSet.Parse(Array.Empty()); + + var combined = IgnoreRuleSet.Combine(parent, child); + + Assert.IsTrue(combined.IsIgnored("archive.par2")); + } + + [TestMethod] + public void Load_ReturnsEmptySet_WhenIgnoreFileMissing() + { + var directory = Path.Combine(Path.GetTempPath(), $"bitcheck_ignore_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(directory); + try + { + var rules = IgnoreRuleSet.Load(directory); + Assert.IsFalse(rules.IsIgnored("anything.par2")); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public void Load_ParsesIgnoreFileFromDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), $"bitcheck_ignore_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(directory); + try + { + File.WriteAllLines(Path.Combine(directory, BitCheckConstants.IgnoreFileName), new[] { "*.par2" }); + + var rules = IgnoreRuleSet.Load(directory); + + Assert.IsTrue(rules.IsIgnored("archive.par2")); + } + finally + { + Directory.Delete(directory, true); + } + } + } +} diff --git a/src/BitCheck/Application/AppOptions.cs b/src/BitCheck/Application/AppOptions.cs index b1f79c4..e5be3d5 100644 --- a/src/BitCheck/Application/AppOptions.cs +++ b/src/BitCheck/Application/AppOptions.cs @@ -12,5 +12,9 @@ public record AppOptions( string? File, bool Delete, bool Info, - bool List); + bool List, + IReadOnlyList? IgnorePatterns = null) + { + public IReadOnlyList IgnorePatterns { get; init; } = IgnorePatterns ?? Array.Empty(); + } } diff --git a/src/BitCheck/Application/BitCheckApplication.cs b/src/BitCheck/Application/BitCheckApplication.cs index e67a0ee..fe4e208 100644 --- a/src/BitCheck/Application/BitCheckApplication.cs +++ b/src/BitCheck/Application/BitCheckApplication.cs @@ -12,6 +12,7 @@ public class BitCheckApplication private readonly ProcessingStats _stats = new(); private readonly CancellationTokenSource _cts = new(); private string? _lastPrintedDirectory; + private readonly IgnoreRuleSet _baseIgnoreRules; /// /// Initializes a new instance of the BitCheckApplication class. @@ -20,6 +21,7 @@ public class BitCheckApplication public BitCheckApplication(AppOptions options) { _options = options; + _baseIgnoreRules = IgnoreRuleSet.Parse(options.IgnorePatterns); } /// @@ -508,7 +510,7 @@ private void ProcessFullDirectory(string rootPath) } using var db = new DatabaseService(dbPath); - ProcessDirectory(fullRootPath, fullRootPath, db); + ProcessDirectory(fullRootPath, fullRootPath, db, _baseIgnoreRules); if (!_cts.IsCancellationRequested && (_options.Check || _options.Update)) { @@ -519,7 +521,7 @@ private void ProcessFullDirectory(string rootPath) } else { - ProcessDirectory(fullRootPath, fullRootPath, null); + ProcessDirectory(fullRootPath, fullRootPath, null, _baseIgnoreRules); } } @@ -529,7 +531,8 @@ private void ProcessFullDirectory(string rootPath) /// The root path for relative key calculations. /// The current directory being processed. /// An optional shared database instance. - private void ProcessDirectory(string rootPath, string currentPath, IDatabaseService? sharedDatabase) + /// Ignore rules inherited from ancestor directories and CLI patterns. + private void ProcessDirectory(string rootPath, string currentPath, IDatabaseService? sharedDatabase, IgnoreRuleSet inheritedIgnoreRules) { var fullPath = Path.GetFullPath(currentPath); if (_options.Verbose) @@ -537,7 +540,10 @@ private void ProcessDirectory(string rootPath, string currentPath, IDatabaseServ Console.WriteLine($"Processing: {fullPath}"); } - var files = FileSystemUtilities.GetEligibleFiles(fullPath); + var ignoreRules = IgnoreRuleSet.Combine(inheritedIgnoreRules, IgnoreRuleSet.Load(fullPath)); + var files = FileSystemUtilities.GetEligibleFiles(fullPath) + .Where(f => !ignoreRules.IsIgnored(Path.GetFileName(f))) + .ToArray(); var database = sharedDatabase ?? CreateDatabase(fullPath); var ownsDatabase = sharedDatabase is null; var useRelativePaths = sharedDatabase != null && _options.SingleDatabase; @@ -579,14 +585,15 @@ private void ProcessDirectory(string rootPath, string currentPath, IDatabaseServ if (_options.Recursive && !_cts.IsCancellationRequested) { - foreach (var subdir in FileSystemUtilities.GetEligibleDirectories(fullPath)) + foreach (var subdir in FileSystemUtilities.GetEligibleDirectories(fullPath) + .Where(d => !ignoreRules.IsIgnored(Path.GetFileName(d)))) { if (_cts.IsCancellationRequested) { break; } - ProcessDirectory(rootPath, subdir, sharedDatabase); + ProcessDirectory(rootPath, subdir, sharedDatabase, ignoreRules); } } } diff --git a/src/BitCheck/Application/BitCheckConstants.cs b/src/BitCheck/Application/BitCheckConstants.cs index 52227f4..1424f42 100644 --- a/src/BitCheck/Application/BitCheckConstants.cs +++ b/src/BitCheck/Application/BitCheckConstants.cs @@ -9,5 +9,10 @@ public static class BitCheckConstants /// Default database file name that is created within scanned directories. /// public const string DatabaseFileName = ".bitcheck.db"; + + /// + /// Name of the per-directory ignore file, auto-discovered like .gitignore. + /// + public const string IgnoreFileName = ".bitcheckignore"; } } diff --git a/src/BitCheck/Application/IgnoreRuleSet.cs b/src/BitCheck/Application/IgnoreRuleSet.cs new file mode 100644 index 0000000..52b15f6 --- /dev/null +++ b/src/BitCheck/Application/IgnoreRuleSet.cs @@ -0,0 +1,119 @@ +using DotNet.Globbing; + +namespace BitCheck.Application +{ + /// + /// A single compiled ignore pattern parsed from a .bitcheckignore file or --ignore-pattern option. + /// + /// The original pattern text (without the leading '!' for negations). + /// Whether this pattern re-includes a previously ignored name. + /// The compiled glob used to match a file or directory basename. + public sealed record IgnoreRule(string Pattern, bool IsNegation, Glob CompiledGlob); + + /// + /// An ordered set of ignore rules matched against file/directory basenames, using git-style + /// last-match-wins precedence with support for '!' negation. + /// + public sealed class IgnoreRuleSet + { + private static readonly IgnoreRuleSet Empty = new(Array.Empty()); + + private readonly IReadOnlyList _rules; + + private IgnoreRuleSet(IReadOnlyList rules) + { + _rules = rules; + } + + /// + /// Parses ignore pattern lines, skipping blank lines and comments (lines starting with '#'). + /// Lines starting with '!' are treated as negation rules. + /// + /// The raw pattern lines to parse. + /// An containing the parsed rules. + public static IgnoreRuleSet Parse(IEnumerable lines) + { + var rules = new List(); + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + var isNegation = line.StartsWith('!'); + var pattern = isNegation ? line[1..] : line; + if (pattern.Length == 0) + { + continue; + } + + rules.Add(new IgnoreRule(pattern, isNegation, Glob.Parse(pattern))); + } + + return rules.Count == 0 ? Empty : new IgnoreRuleSet(rules); + } + + /// + /// Loads the .bitcheckignore file from the specified directory, if present. + /// + /// The directory to look for a .bitcheckignore file in. + /// The parsed rule set, or an empty set if no ignore file exists. + public static IgnoreRuleSet Load(string directoryPath) + { + var ignoreFilePath = Path.Combine(directoryPath, BitCheckConstants.IgnoreFileName); + if (!File.Exists(ignoreFilePath)) + { + return Empty; + } + + return Parse(File.ReadAllLines(ignoreFilePath)); + } + + /// + /// Combines a parent rule set with a child (nested directory) rule set. Child rules are + /// evaluated after parent rules, so a nested .bitcheckignore can override inherited rules. + /// + /// The inherited rule set. + /// The rule set to apply on top of the parent. + /// A combined rule set. + public static IgnoreRuleSet Combine(IgnoreRuleSet parent, IgnoreRuleSet child) + { + if (child._rules.Count == 0) + { + return parent; + } + + if (parent._rules.Count == 0) + { + return child; + } + + var combined = new List(parent._rules.Count + child._rules.Count); + combined.AddRange(parent._rules); + combined.AddRange(child._rules); + return new IgnoreRuleSet(combined); + } + + /// + /// Determines whether the specified file or directory name is ignored, using last-match-wins + /// precedence: the most recently added matching rule decides the outcome. + /// + /// The file or directory basename to test. + /// true if the name is ignored, otherwise false. + public bool IsIgnored(string name) + { + var ignored = false; + foreach (var rule in _rules) + { + if (rule.CompiledGlob.IsMatch(name)) + { + ignored = !rule.IsNegation; + } + } + + return ignored; + } + } +} diff --git a/src/BitCheck/BitCheck.csproj b/src/BitCheck/BitCheck.csproj index df7c901..4413a37 100644 --- a/src/BitCheck/BitCheck.csproj +++ b/src/BitCheck/BitCheck.csproj @@ -20,6 +20,7 @@ + diff --git a/src/BitCheck/Program.cs b/src/BitCheck/Program.cs index c853b67..c447801 100644 --- a/src/BitCheck/Program.cs +++ b/src/BitCheck/Program.cs @@ -38,6 +38,7 @@ static async Task Main(string[] args) private static Option _deleteOption = null!; private static Option _infoOption = null!; private static Option _listOption = null!; + private static Option _ignorePatternOption = null!; /// /// Builds the root System.CommandLine command that drives BitCheck. @@ -68,6 +69,10 @@ private static RootCommand BuildRootCommand() _infoOption.AddAlias("-i"); _listOption = new Option("--list", "List all files tracked in the database"); _listOption.AddAlias("-l"); + _ignorePatternOption = new Option("--ignore-pattern", "Glob pattern to ignore (repeatable); combines with any .bitcheckignore files found while scanning") + { + AllowMultipleArgumentsPerToken = true + }; var rootCommand = new RootCommand { @@ -82,7 +87,8 @@ private static RootCommand BuildRootCommand() _fileOption, _deleteOption, _infoOption, - _listOption + _listOption, + _ignorePatternOption }; rootCommand.Description = @" @@ -121,7 +127,8 @@ private static int HandleCommand(InvocationContext context) context.ParseResult.GetValueForOption(_fileOption), context.ParseResult.GetValueForOption(_deleteOption), context.ParseResult.GetValueForOption(_infoOption), - context.ParseResult.GetValueForOption(_listOption)); + context.ParseResult.GetValueForOption(_listOption), + context.ParseResult.GetValueForOption(_ignorePatternOption) ?? Array.Empty()); return new BitCheckApplication(options).Run(); }