Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd76633211 |
@@ -29,12 +29,11 @@
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
|
||||
it. It leads with what the program is and how to use it. A reference section
|
||||
covers every setting and every subcommand. Development and testing come last.
|
||||
Every statement was checked against the code on `next`. Five comments in
|
||||
`main_test.go` cite "Settings", where their rules now live (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
- an operand under another is walked on its own when a directory between them,
|
||||
the inner operand included, is named `.zfs` or is a symlink. The walk of the
|
||||
outer operand does not reach it then. `scan` now records the files below it
|
||||
and no longer deletes their records (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||
|
||||
- a file or directory removed while the scan runs is not an error, in the walk
|
||||
or in the hash phase. The scan prints no line for it and does not count it as
|
||||
|
||||
+7
-7
@@ -393,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
|
||||
// README §Settings: the path names the file exactly. With link a
|
||||
// README §Database: the path names the file exactly. With link a
|
||||
// symlink to the directory real/sub, link/../db.sqlite names
|
||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||
// use that file, relative or absolute, and its first line must name
|
||||
@@ -648,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
// README §Settings: --workers below 1 is a usage error reported in
|
||||
// README §scan mode: --workers below 1 is a usage error reported in
|
||||
// one line on stderr, before the scan opens the database.
|
||||
for _, workers := range []string{"0", "-1"} {
|
||||
t.Run(workers, func(t *testing.T) {
|
||||
@@ -686,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
func TestRunHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Settings: help goes to stderr, exits 0, and leaves stdout
|
||||
// empty.
|
||||
// README §Subcommands: help goes to stderr, exits 0, and leaves
|
||||
// stdout empty.
|
||||
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
||||
|
||||
for _, args := range cases {
|
||||
@@ -713,8 +713,8 @@ func TestRunHelp(t *testing.T) {
|
||||
func TestRunVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Settings: the version is one line on stdout, with nothing
|
||||
// on stderr, and exits 0.
|
||||
// README §Subcommands: the version is one line on stdout, with
|
||||
// nothing on stderr, and exits 0.
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
|
||||
for _, arg := range []string{"--version", "-v"} {
|
||||
@@ -1122,7 +1122,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
||||
}
|
||||
|
||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
||||
// README §Settings: the path names the database file exactly. In
|
||||
// README §Database: the path names the database file exactly. In
|
||||
// SQLite's connection string an unescaped ? or # would end the file
|
||||
// name and % would start an escape, and a path starting with //
|
||||
// could be read as a host name. %25 is a valid escape, so unescaped
|
||||
|
||||
@@ -48,6 +48,11 @@ const sampleWindow = 1024 * 1024
|
||||
// and hash worker pools.
|
||||
const workQueueDepth = 1024
|
||||
|
||||
// zfsDirName is the name of the directory in which ZFS shows a
|
||||
// filesystem's snapshots. The walk never enters one, since it would
|
||||
// list every file once per snapshot.
|
||||
const zfsDirName = ".zfs"
|
||||
|
||||
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
|
||||
// has already printed its line, so run prints nothing more.
|
||||
var errInterrupted = errors.New("scan interrupted")
|
||||
@@ -272,9 +277,10 @@ func pruneRoots(roots []string, oneFS bool, rules walkRules,
|
||||
}
|
||||
|
||||
// walkReaches reports whether the walk of operand above reaches r, an
|
||||
// operand under it. That walk does not enter a default exclude in
|
||||
// rules, nor a directory that device puts on a filesystem in rules that
|
||||
// is not persistent, nor, with -x, one that device puts on a different
|
||||
// operand under it. That walk never follows a symlink. It does not
|
||||
// enter a directory named .zfs, nor a default exclude in rules, nor a
|
||||
// directory that device puts on a filesystem in rules that is not
|
||||
// persistent, nor, with -x, one that device puts on a different
|
||||
// filesystem than above, when device knows above's. It reaches every
|
||||
// regular file in a directory it enters, so for a regular-file operand
|
||||
// the directories checked start at its directory; they end below above.
|
||||
@@ -295,6 +301,15 @@ func walkReaches(above, r string, oneFS bool, rules walkRules,
|
||||
return false
|
||||
}
|
||||
|
||||
if filepath.Base(dir) == zfsDirName {
|
||||
return false
|
||||
}
|
||||
|
||||
fi, err = os.Lstat(dir)
|
||||
if err == nil && fi.Mode()&fs.ModeSymlink != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
dev, ok := device(dir)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -883,7 +898,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
||||
case mode.IsRegular():
|
||||
return ""
|
||||
case mode.IsDir():
|
||||
if filepath.Base(root) != ".zfs" {
|
||||
if filepath.Base(root) != zfsDirName {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1153,7 +1168,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
||||
parent dirJob, oneFS bool, rules walkRules,
|
||||
events chan<- walkEvent,
|
||||
) (dirJob, bool) {
|
||||
if e.Name() == ".zfs" {
|
||||
if e.Name() == zfsDirName {
|
||||
return dirJob{}, false
|
||||
}
|
||||
|
||||
|
||||
@@ -1672,6 +1672,81 @@ func TestSyncScanOverlappingRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanWalksOperandInZFSDirectory checks that an operand inside a
|
||||
// directory named .zfs below another operand is walked on its own,
|
||||
// since the walk of the other operand does not enter .zfs. The file
|
||||
// under it is recorded, and a second scan keeps the record.
|
||||
func TestScanWalksOperandInZFSDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
f := writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
||||
daily := filepath.Dir(f)
|
||||
|
||||
syncTree(t, db, dir, daily)
|
||||
recordByPath(t, dbRecords(t, db), f)
|
||||
|
||||
syncTree(t, db, dir, daily)
|
||||
recordByPath(t, dbRecords(t, db), f)
|
||||
}
|
||||
|
||||
// TestScanWalksOperandBehindSymlink checks that an operand reached
|
||||
// through a symlinked directory below another operand is walked on its
|
||||
// own, since the walk of the other operand does not follow the symlink.
|
||||
// The file under it is recorded, and a second scan keeps the record.
|
||||
func TestScanWalksOperandBehindSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
writeFile(t, target, "sub/f", pattern(1, 10))
|
||||
|
||||
link := filepath.Join(dir, "link")
|
||||
|
||||
err := os.Symlink(target, link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := filepath.Join(link, "sub")
|
||||
f := filepath.Join(sub, "f")
|
||||
|
||||
syncTree(t, db, dir, sub)
|
||||
recordByPath(t, dbRecords(t, db), f)
|
||||
|
||||
syncTree(t, db, dir, sub)
|
||||
recordByPath(t, dbRecords(t, db), f)
|
||||
}
|
||||
|
||||
// TestScanDropsOperandReachedBehindSymlink checks that an operand under
|
||||
// another one is still dropped when both lie behind the same symlinked
|
||||
// directory: the walk of the other operand starts past the symlink and
|
||||
// reaches it, so the file under it is walked once.
|
||||
func TestScanDropsOperandReachedBehindSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
writeFile(t, target, "sub/deeper/f", pattern(1, 10))
|
||||
|
||||
link := filepath.Join(dir, "link")
|
||||
|
||||
err := os.Symlink(target, link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := filepath.Join(link, "sub")
|
||||
|
||||
st := syncTree(t, db, sub, filepath.Join(sub, "deeper"))
|
||||
if st != (scanStats{walked: 1, added: 1}) {
|
||||
t.Fatalf("stats = %+v, want 1 walked and added", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanHashesUniqueSize checks that a file whose size no other file
|
||||
// shares is hashed like every other file.
|
||||
func TestScanHashesUniqueSize(t *testing.T) {
|
||||
@@ -2085,6 +2160,16 @@ func TestPruneRoots(t *testing.T) {
|
||||
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
||||
pqRoot, got, want)
|
||||
}
|
||||
|
||||
// Nor does it enter a directory named .zfs, so /p/.zfs/s is kept. The
|
||||
// walk of /p/.zfs/s reaches /p/.zfs/s/t, which is dropped.
|
||||
got = pruneRoots([]string{"/p", "/p/.zfs/s", "/p/.zfs/s/t"}, false,
|
||||
walkRules{}, device)
|
||||
|
||||
want = []string{"/p", "/p/.zfs/s"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("pruneRoots with .zfs = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user