diff --git a/.gitignore b/.gitignore
index dc0470a..e043c9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -203,6 +203,11 @@ PublishScripts/
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
+# and except a Unity project's Packages/, which is source: Unity's package manifest and its
+# resolved lock file are both meant to be committed, and a NuGet restore folder never contains
+# a file by either name.
+!**/[Pp]ackages/manifest.json
+!**/[Pp]ackages/packages-lock.json
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
@@ -651,3 +656,16 @@ Temporary Items
# ImGui.ini files
imgui.ini
+
+# Game engine projects
+#
+# Godot: the import cache, and the mono/temp bin+obj a C# build writes.
+.godot/
+
+# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule
+# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs
+# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently
+# breaks those references - including for a plug-in whose .dll is itself a build output. This
+# negation has to come after that rule to win, and is scoped to the asset tree so the Visual
+# Studio artifact stays ignored everywhere else.
+!**/[Aa]ssets/**/*.meta
diff --git a/FileDeduplicator.Test/FileScannerAndHasherTests.cs b/FileDeduplicator.Test/FileScannerAndHasherTests.cs
index 9f65b9a..0ffad50 100644
--- a/FileDeduplicator.Test/FileScannerAndHasherTests.cs
+++ b/FileDeduplicator.Test/FileScannerAndHasherTests.cs
@@ -164,4 +164,36 @@ public void ParallelHashingAgreesWithSingleFileHashing()
Assert.AreEqual(FileHasher.ComputeHash(file), hashes[file]);
}
}
+
+ ///
+ /// One file the process cannot read must not take the whole hashing pass down with it. Hashing
+ /// runs under Parallel.ForEach, so an exception escaping the delegate surfaces as an
+ /// and discards the results every other thread had already
+ /// produced.
+ ///
+ [TestMethod]
+ public void HashingSkipsAnUnreadableFileAndStillHashesTheRest()
+ {
+ // Arrange
+ using TempTree tree = new();
+ AbsoluteFilePath readable = tree.Write("readable.txt", "one");
+ AbsoluteFilePath alsoReadable = tree.Write("also-readable.txt", "two");
+
+ // A directory standing in for a file: File.OpenRead throws UnauthorizedAccessException for
+ // one on every platform and for every user. Denying permission on a real file would not --
+ // a privileged process, root or an elevated CI runner, reads it anyway -- so that
+ // arrangement would pass whether or not the exception is handled.
+ string deniedPath = Path.Combine(tree.Root.WeakString, "denied");
+ _ = Directory.CreateDirectory(deniedPath);
+ AbsoluteFilePath unreadable = deniedPath.As();
+
+ // Act
+ Dictionary hashes = FileHasher.HashFiles([readable, alsoReadable, unreadable]);
+
+ // Assert
+ Assert.HasCount(2, hashes);
+ Assert.AreEqual(FileHasher.ComputeHash(readable), hashes[readable]);
+ Assert.AreEqual(FileHasher.ComputeHash(alsoReadable), hashes[alsoReadable]);
+ Assert.IsFalse(hashes.ContainsKey(unreadable));
+ }
}
diff --git a/FileDeduplicator/FileHasher.cs b/FileDeduplicator/FileHasher.cs
index 4a6c00c..bfb5ff0 100644
--- a/FileDeduplicator/FileHasher.cs
+++ b/FileDeduplicator/FileHasher.cs
@@ -32,16 +32,29 @@ internal static Dictionary HashFiles(IReadOnlyList(results);
}
+ private static void ReportSkipped(AbsoluteFilePath filePath, Exception ex)
+ {
+ lock (ConsoleLock)
+ {
+ Console.WriteLine($" Error hashing {filePath.FileName}: {ex.Message}");
+ }
+ }
+
internal static string ComputeHash(AbsoluteFilePath filePath)
{
using FileStream stream = File.OpenRead(filePath.WeakString);