From 6b7d914b5861ff67315735a68c04539b8c0b7059 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:25:46 +0000 Subject: [PATCH 1/2] Skip bare git repositories, CVS and _darcs when scanning [patch] A bare repository keeps HEAD, objects/ and refs/ directly in a folder of any name, so the .git name check missed it and Deduplicate deleted one of two branch refs at the same commit. Recognise a gitdir by that structure, and add CVS and _darcs to the skipped metadata names. Fixes ktsu-dev/FileDeduplicator#198 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2ncKULZZ8pQtc75wf --- .../VersionControlMetadataTests.cs | 61 +++++++++++++++++++ FileDeduplicator/FileScanner.cs | 28 ++++++++- README.md | 2 +- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/FileDeduplicator.Test/VersionControlMetadataTests.cs b/FileDeduplicator.Test/VersionControlMetadataTests.cs index 1f5a2a8..aee4731 100644 --- a/FileDeduplicator.Test/VersionControlMetadataTests.cs +++ b/FileDeduplicator.Test/VersionControlMetadataTests.cs @@ -25,6 +25,8 @@ public sealed class VersionControlMetadataTests [DataRow(".svn")] [DataRow(".hg")] [DataRow(".bzr")] + [DataRow("CVS")] + [DataRow("_darcs")] [DataRow(".GIT")] public void TheScanSkipsVersionControlMetadataDirectories(string metadata) { @@ -61,6 +63,65 @@ public void TheScanStillReadsFilesWhoseNamesOnlyResembleMetadata() CollectionAssert.AreEquivalent(expected, files.ToArray()); } + [TestMethod] + [DataRow("srv/project.git")] + [DataRow("mirror")] + [DataRow(".cfg")] + public void TheScanSkipsABareGitRepositoryWhateverItsName(string bare) + { + // Arrange -- a bare repository keeps HEAD, objects/ and refs/ directly in a folder of any name + using TempTree tree = new(); + AbsoluteFilePath outside = tree.Write("readme.txt", "content"); + _ = tree.Write($"{bare}/HEAD", "ref: refs/heads/main\n"); + _ = tree.Write($"{bare}/objects/info/packs", "\n"); + _ = tree.Write($"{bare}/refs/heads/main", BranchTip); + _ = tree.Write($"{bare}/refs/heads/dev", BranchTip); + + // Act + IReadOnlyList files = FileScanner.ScanForFiles(tree.Root); + + // Assert + CollectionAssert.AreEquivalent(new[] { outside }, files.ToArray()); + } + + [TestMethod] + public void TheScanStillReadsAFolderThatHasOnlyPartOfAGitDirectoryLayout() + { + // Arrange -- HEAD and refs/ without objects/ is not a gitdir, whatever the folder is called + using TempTree tree = new(); + AbsoluteFilePath[] expected = + [ + tree.Write("notes.git/HEAD", "ref: refs/heads/main\n"), + tree.Write("notes.git/refs/heads/a", BranchTip), + tree.Write("notes.git/refs/heads/b", BranchTip), + ]; + + // Act + IReadOnlyList files = FileScanner.ScanForFiles(tree.Root); + + // Assert + CollectionAssert.AreEquivalent(expected, files.ToArray()); + } + + [TestMethod] + public void DeduplicateLeavesTheBranchesOfABareRepositoryAlone() + { + // Arrange -- the reproduction from ktsu-dev/FileDeduplicator#198 + using TempTree tree = new(); + _ = tree.Write("srv/project.git/HEAD", "ref: refs/heads/main\n"); + _ = tree.Write("srv/project.git/objects/info/packs", "\n"); + AbsoluteFilePath main = tree.Write("srv/project.git/refs/heads/main", BranchTip); + AbsoluteFilePath dev = tree.Write("srv/project.git/refs/heads/dev", BranchTip); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = tree.Root.WeakString }, "y")); + + // Assert + Assert.IsTrue(TempTree.Exists(main), $"refs/heads/main was deleted. Output was:\n{output}"); + Assert.IsTrue(TempTree.Exists(dev), $"refs/heads/dev was deleted. Output was:\n{output}"); + Assert.DoesNotContain("project.git", output, $"The run reported a path inside the bare repository. Output was:\n{output}"); + } + [TestMethod] public void DeduplicateDeletesOnlyTheDuplicatesOutsideGitMetadata() { diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs index b84fe56..e85a301 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -52,6 +52,12 @@ internal static class FileScanner /// reclaim a few hundred bytes (ktsu-dev/FileDeduplicator#192). Matched on the directory's own /// name, ignoring case, so a file whose path merely contains ".git" is still scanned, and so is a /// .git file (the pointer a worktree or submodule leaves), which is not a directory. + /// + /// CVS is here because every directory of a CVS checkout holds a CVS folder whose + /// Root and Repository files are identical across the whole checkout. A bare git + /// repository can have any name, so it is recognised by its structure instead, in + /// . + /// /// private static readonly HashSet VersionControlDirectories = new(StringComparer.OrdinalIgnoreCase) { @@ -59,8 +65,27 @@ internal static class FileScanner ".svn", ".hg", ".bzr", + "CVS", + "_darcs", }; + /// + /// Whether is a git directory by structure: a HEAD file next + /// to objects and refs directories, the same test git uses to recognise a gitdir. + /// + /// + /// A bare repository (project.git on a server, a git clone --mirror, the bare-dotfiles + /// ~/.cfg) keeps its refs directly in a directory of any name, so the name list above misses + /// it, and two branches at the same commit are byte-identical files (ktsu-dev/FileDeduplicator#198). + /// A folder that only ends in ".git" without that structure is still scanned. + /// + /// The full path of the directory. + /// when the directory holds git metadata. + internal static bool IsGitDirectory(string directory) => + File.Exists(Path.Combine(directory, "HEAD")) + && Directory.Exists(Path.Combine(directory, "objects")) + && Directory.Exists(Path.Combine(directory, "refs")); + /// /// Checks that is a directory that can be scanned, and says why in one /// line when it is not. @@ -100,7 +125,8 @@ internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPa WalkOptions) { ShouldIncludePredicate = (ref entry) => !entry.IsDirectory, - ShouldRecursePredicate = (ref entry) => !VersionControlDirectories.Contains(entry.FileName.ToString()), + ShouldRecursePredicate = (ref entry) => + !VersionControlDirectories.Contains(entry.FileName.ToString()) && !IsGitDirectory(entry.ToFullPath()), }; List files = []; diff --git a/README.md b/README.md index 55861e6..11648b9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ When duplicates are found, the file with the **shortest filename** is kept. If t Empty files are reported as a group but never deleted. Deleting one frees nothing, and an empty file such as `__init__.py`, `.gitkeep` or `py.typed` usually matters because it exists. -Version-control metadata is never scanned. The `.git`, `.svn`, `.hg` and `.bzr` directories under the root are skipped, because their small files are routinely identical by design: every branch ref pointing at the same commit has the same content, and deleting one deletes the branch. +Version-control metadata is never scanned. The `.git`, `.svn`, `.hg`, `.bzr`, `CVS` and `_darcs` directories under the root are skipped, and so is a bare git repository of any name (`project.git`, a `git clone --mirror`, `~/.cfg`), recognised by a `HEAD` file next to `objects` and `refs` directories. They are skipped because their small files are routinely identical by design: every branch ref pointing at the same commit has the same content, and deleting one deletes the branch. One file reached at two paths is not a duplicate of itself. A hardlink, a bind mount, or a share mounted twice gives one file several paths, and deleting one of them either deletes the copy being kept or frees nothing. Such paths are counted as one file, and deletion never removes a path that leads to the kept file. From 3f067f872a44ecdb9075ce13deedddcbf2881df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:30:48 +0000 Subject: [PATCH 2/2] Join the gitdir child names with Path.Join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2ncKULZZ8pQtc75wf --- FileDeduplicator/FileScanner.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs index e85a301..97b410f 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -82,9 +82,9 @@ internal static class FileScanner /// The full path of the directory. /// when the directory holds git metadata. internal static bool IsGitDirectory(string directory) => - File.Exists(Path.Combine(directory, "HEAD")) - && Directory.Exists(Path.Combine(directory, "objects")) - && Directory.Exists(Path.Combine(directory, "refs")); + File.Exists(Path.Join(directory, "HEAD")) + && Directory.Exists(Path.Join(directory, "objects")) + && Directory.Exists(Path.Join(directory, "refs")); /// /// Checks that is a directory that can be scanned, and says why in one