Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd76633211 |
No files matched your search
@@ -29,12 +29,11 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
|
- an operand under another is walked on its own when a directory between them,
|
||||||
it. It leads with what the program is and how to use it. A reference section
|
the inner operand included, is named `.zfs` or is a symlink. The walk of the
|
||||||
covers every setting and every subcommand. Development and testing come last.
|
outer operand does not reach it then. `scan` now records the files below it
|
||||||
Every statement was checked against the code on `next`. Five comments in
|
and no longer deletes their records (2026-10-26,
|
||||||
`main_test.go` cite "Settings", where their rules now live (2026-10-26,
|
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/135)
|
|
||||||
|
|
||||||
- a file or directory removed while the scan runs is not an error, in the walk
|
- 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
|
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) {
|
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
|
// symlink to the directory real/sub, link/../db.sqlite names
|
||||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||||
// use that file, relative or absolute, and its first line must name
|
// 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) {
|
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.
|
// one line on stderr, before the scan opens the database.
|
||||||
for _, workers := range []string{"0", "-1"} {
|
for _, workers := range []string{"0", "-1"} {
|
||||||
t.Run(workers, func(t *testing.T) {
|
t.Run(workers, func(t *testing.T) {
|
||||||
@@ -686,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
|||||||
func TestRunHelp(t *testing.T) {
|
func TestRunHelp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README §Settings: help goes to stderr, exits 0, and leaves stdout
|
// README §Subcommands: help goes to stderr, exits 0, and leaves
|
||||||
// empty.
|
// stdout empty.
|
||||||
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
||||||
|
|
||||||
for _, args := range cases {
|
for _, args := range cases {
|
||||||
@@ -713,8 +713,8 @@ func TestRunHelp(t *testing.T) {
|
|||||||
func TestRunVersion(t *testing.T) {
|
func TestRunVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README §Settings: the version is one line on stdout, with nothing
|
// README §Subcommands: the version is one line on stdout, with
|
||||||
// on stderr, and exits 0.
|
// nothing on stderr, and exits 0.
|
||||||
info, _ := debug.ReadBuildInfo()
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|
||||||
for _, arg := range []string{"--version", "-v"} {
|
for _, arg := range []string{"--version", "-v"} {
|
||||||
@@ -1122,7 +1122,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
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
|
// SQLite's connection string an unescaped ? or # would end the file
|
||||||
// name and % would start an escape, and a path starting with //
|
// 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
|
// 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.
|
// and hash worker pools.
|
||||||
const workQueueDepth = 1024
|
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
|
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
|
||||||
// has already printed its line, so run prints nothing more.
|
// has already printed its line, so run prints nothing more.
|
||||||
var errInterrupted = errors.New("scan interrupted")
|
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
|
// walkReaches reports whether the walk of operand above reaches r, an
|
||||||
// operand under it. That walk does not enter a default exclude in
|
// operand under it. That walk never follows a symlink. It does not
|
||||||
// rules, nor a directory that device puts on a filesystem in rules that
|
// enter a directory named .zfs, nor a default exclude in rules, nor a
|
||||||
// is not persistent, nor, with -x, one that device puts on a different
|
// 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
|
// filesystem than above, when device knows above's. It reaches every
|
||||||
// regular file in a directory it enters, so for a regular-file operand
|
// regular file in a directory it enters, so for a regular-file operand
|
||||||
// the directories checked start at its directory; they end below above.
|
// 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
|
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)
|
dev, ok := device(dir)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -883,7 +898,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
|||||||
case mode.IsRegular():
|
case mode.IsRegular():
|
||||||
return ""
|
return ""
|
||||||
case mode.IsDir():
|
case mode.IsDir():
|
||||||
if filepath.Base(root) != ".zfs" {
|
if filepath.Base(root) != zfsDirName {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,7 +1168,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
parent dirJob, oneFS bool, rules walkRules,
|
parent dirJob, oneFS bool, rules walkRules,
|
||||||
events chan<- walkEvent,
|
events chan<- walkEvent,
|
||||||
) (dirJob, bool) {
|
) (dirJob, bool) {
|
||||||
if e.Name() == ".zfs" {
|
if e.Name() == zfsDirName {
|
||||||
return dirJob{}, false
|
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
|
// TestScanHashesUniqueSize checks that a file whose size no other file
|
||||||
// shares is hashed like every other file.
|
// shares is hashed like every other file.
|
||||||
func TestScanHashesUniqueSize(t *testing.T) {
|
func TestScanHashesUniqueSize(t *testing.T) {
|
||||||
@@ -2085,6 +2160,16 @@ func TestPruneRoots(t *testing.T) {
|
|||||||
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
||||||
pqRoot, got, want)
|
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) {
|
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user