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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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)
Expand Down
156 changes: 156 additions & 0 deletions src/BitCheck.Tests/ApplicationTests/IgnorePatternTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
110 changes: 110 additions & 0 deletions src/BitCheck.Tests/IgnoreRuleSetTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>());

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);
}
}
}
}
6 changes: 5 additions & 1 deletion src/BitCheck/Application/AppOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ public record AppOptions(
string? File,
bool Delete,
bool Info,
bool List);
bool List,
IReadOnlyList<string>? IgnorePatterns = null)
{
public IReadOnlyList<string> IgnorePatterns { get; init; } = IgnorePatterns ?? Array.Empty<string>();
}
}
Loading
Loading