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
61 changes: 61 additions & 0 deletions FileDeduplicator.Test/VersionControlMetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
[DataRow(".svn")]
[DataRow(".hg")]
[DataRow(".bzr")]
[DataRow("CVS")]
[DataRow("_darcs")]
[DataRow(".GIT")]
public void TheScanSkipsVersionControlMetadataDirectories(string metadata)
{
Expand Down Expand Up @@ -61,6 +63,65 @@
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<AbsoluteFilePath> files = FileScanner.ScanForFiles(tree.Root);

// Assert
CollectionAssert.AreEquivalent(new[] { outside }, files.ToArray());

Check warning on line 84 in FileDeduplicator.Test/VersionControlMetadataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEquivalent'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaGhLRUONVFCbuWN0eSO&open=AaGhLRUONVFCbuWN0eSO&pullRequest=205
}

[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<AbsoluteFilePath> files = FileScanner.ScanForFiles(tree.Root);

// Assert
CollectionAssert.AreEquivalent(expected, files.ToArray());

Check warning on line 103 in FileDeduplicator.Test/VersionControlMetadataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEquivalent'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaGhLRUONVFCbuWN0eSP&open=AaGhLRUONVFCbuWN0eSP&pullRequest=205
}

[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()
{
Expand Down
28 changes: 27 additions & 1 deletion FileDeduplicator/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,40 @@ 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
/// <c>.git</c> <em>file</em> (the pointer a worktree or submodule leaves), which is not a directory.
/// <para>
/// <c>CVS</c> is here because every directory of a CVS checkout holds a <c>CVS</c> folder whose
/// <c>Root</c> and <c>Repository</c> files are identical across the whole checkout. A bare git
/// repository can have any name, so it is recognised by its structure instead, in
/// <see cref="IsGitDirectory"/>.
/// </para>
/// </remarks>
private static readonly HashSet<string> VersionControlDirectories = new(StringComparer.OrdinalIgnoreCase)
{
".git",
".svn",
".hg",
".bzr",
"CVS",
"_darcs",
};

/// <summary>
/// Whether <paramref name="directory"/> is a git directory by structure: a <c>HEAD</c> file next
/// to <c>objects</c> and <c>refs</c> directories, the same test git uses to recognise a gitdir.
/// </summary>
/// <remarks>
/// A bare repository (<c>project.git</c> on a server, a <c>git clone --mirror</c>, the bare-dotfiles
/// <c>~/.cfg</c>) 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.
/// </remarks>
/// <param name="directory">The full path of the directory.</param>
/// <returns><see langword="true"/> when the directory holds git metadata.</returns>
internal static bool IsGitDirectory(string directory) =>
File.Exists(Path.Join(directory, "HEAD"))
&& Directory.Exists(Path.Join(directory, "objects"))
&& Directory.Exists(Path.Join(directory, "refs"));

/// <summary>
/// Checks that <paramref name="path"/> is a directory that can be scanned, and says why in one
/// line when it is not.
Expand Down Expand Up @@ -100,7 +125,8 @@ internal static IReadOnlyList<AbsoluteFilePath> 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<AbsoluteFilePath> files = [];
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading