From afb404b1c10f34bd1b39525087a3e1797b74cdf7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 7 Oct 2026 21:21:38 +0000 Subject: [PATCH] Store mtime in nanoseconds so a same-second rewrite is re-hashed (closes #12) scan recorded mtime in whole seconds, so a file rewritten in place at the same size within the same second as its recorded mtime was classed unchanged and kept its old hashes. The walk, a file given as an operand, and the content phase's recheck now all use ModTime().UnixNano(); the change rule itself is unchanged. The column stays INTEGER and PRAGMA user_version stays 1, per the owner's ruling. A database written by an earlier build holds seconds, so its files count as changed once, at the next scan that covers them. README states the new resolution. Model: opus-5-5 --- README.md | 5 ++++- TODO.md | 4 ++++ scan.go | 6 +++--- scan_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 41afbc8..c41f1ce 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ All three subcommands operate on a single SQLite database file: CREATE TABLE files ( path BLOB PRIMARY KEY, -- absolute path, raw bytes size INTEGER NOT NULL, -- bytes, from lstat - mtime INTEGER NOT NULL, -- Unix seconds, from lstat + mtime INTEGER NOT NULL, -- Unix nanoseconds, from lstat head TEXT NOT NULL, -- lowercase-hex SHA-256; first 64 KiB, or whole file under 10 MiB tail TEXT NOT NULL, -- lowercase-hex SHA-256; last 64 KiB, or whole file under 10 MiB content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples @@ -421,6 +421,9 @@ operands: once its size, `head`, and `tail` match another record's. - A file whose mtime is newer than recorded, or whose size differs, is processed as if new: re-hashed, or recorded without hashes, per the shared-size rule. + The mtimes are compared in nanoseconds, as finely as the filesystem records + them, so a same-size rewrite later in the same second as the recorded mtime + still counts as a change. - A database record whose path lies under one of the scanned operands but was not successfully processed this run is deleted. This removes records for deleted files. It also removes records for paths that failed to stat or hash diff --git a/TODO.md b/TODO.md index a538990..9cf2d69 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,10 @@ # Completed Steps +- `scan` records mtime in Unix nanoseconds and compares it at that resolution, + so a same-size rewrite within the same second is re-hashed (2026-10-07, + https://git.eeqj.de/sneak/sfdupes/issues/12) + - cut the narration from `TODO.md` Completed Steps and from the comments in `script/` and both Dockerfiles; §Workflow now branches from and merges to `next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49) diff --git a/scan.go b/scan.go index f181f0a..2af80f0 100644 --- a/scan.go +++ b/scan.go @@ -807,7 +807,7 @@ func unchangedFile(r scanRec) (fileRec, bool, error) { } if !fi.Mode().IsRegular() || fi.Size() != r.size || - fi.ModTime().Unix() > r.mtime { + fi.ModTime().UnixNano() > r.mtime { return fileRec{}, false, nil } @@ -966,7 +966,7 @@ func seedRoot(ctx context.Context, root string, sendEvent(ctx, events, walkEvent{rec: fileRec{ path: root, size: fi.Size(), - mtime: fi.ModTime().Unix(), + mtime: fi.ModTime().UnixNano(), dev: dev, ino: ino, }}) @@ -1124,7 +1124,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry, sendEvent(ctx, events, walkEvent{rec: fileRec{ path: p, size: info.Size(), - mtime: info.ModTime().Unix(), + mtime: info.ModTime().UnixNano(), dev: dev, ino: ino, }}) diff --git a/scan_test.go b/scan_test.go index d2560b5..2610e89 100644 --- a/scan_test.go +++ b/scan_test.go @@ -1285,8 +1285,53 @@ func TestSyncScanMtimeBump(t *testing.T) { t.Fatalf("mtime-bump stats = %+v, want 1 updated", st) } - if r := recordByPath(t, dbRecords(t, db), a); r.mtime != future.Unix() { - t.Fatalf("mtime = %d, want %d", r.mtime, future.Unix()) + if r := recordByPath(t, dbRecords(t, db), a); r.mtime != future.UnixNano() { + t.Fatalf("mtime = %d, want %d", r.mtime, future.UnixNano()) + } +} + +// TestSyncScanSameSecondRewrite rewrites a file in place at the same +// size with an mtime later in the same second as the recorded one: the +// next scan must notice the change and re-hash the file. +func TestSyncScanSameSecondRewrite(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + db := openTestDB(t) + a := writeFile(t, dir, "a.bin", pattern(1, 500)) + + // b.bin shares the size of a.bin, so a.bin is hashed. + writeFile(t, dir, "b.bin", pattern(2, 500)) + + first := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC) + + err := os.Chtimes(a, first, first) + if err != nil { + t.Fatal(err) + } + + syncTree(t, db, dir) + + rewritten := pattern(3, 500) + writeFile(t, dir, "a.bin", rewritten) + + later := first.Add(500 * time.Millisecond) + + err = os.Chtimes(a, later, later) + if err != nil { + t.Fatal(err) + } + + st := syncTree(t, db, dir) + if st != (scanStats{walked: 2, updated: 1, unchanged: 1}) { + t.Fatalf("rescan stats = %+v, want 1 updated 1 unchanged", st) + } + + r := recordByPath(t, dbRecords(t, db), a) + if want := hexSum(rewritten); r.head != want || r.tail != want || + r.content != want { + t.Fatalf("head, tail, content = %q, %q, %q, want %q for each", + r.head, r.tail, r.content, want) } } @@ -1335,7 +1380,7 @@ func TestSyncScanSizeChange(t *testing.T) { writeFile(t, dir, "f", pattern(1, 200)) - mt := time.Unix(old.mtime, 0) + mt := time.Unix(0, old.mtime) err := os.Chtimes(p, mt, mt) if err != nil {