Store mtime in nanoseconds so a same-second rewrite is re-hashed (closes #12)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-07 21:21:38 +00:00
parent 0064eba542
commit afb404b1c1
4 changed files with 59 additions and 7 deletions
+4 -1
View File
@@ -314,7 +314,7 @@ All three subcommands operate on a single SQLite database file:
CREATE TABLE files ( CREATE TABLE files (
path BLOB PRIMARY KEY, -- absolute path, raw bytes path BLOB PRIMARY KEY, -- absolute path, raw bytes
size INTEGER NOT NULL, -- bytes, from lstat 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 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 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 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. once its size, `head`, and `tail` match another record's.
- A file whose mtime is newer than recorded, or whose size differs, is processed - 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. 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 - 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 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 deleted files. It also removes records for paths that failed to stat or hash
+4
View File
@@ -29,6 +29,10 @@
# Completed Steps # 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 - cut the narration from `TODO.md` Completed Steps and from the comments in
`script/` and both Dockerfiles; §Workflow now branches from and merges to `script/` and both Dockerfiles; §Workflow now branches from and merges to
`next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49) `next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
+3 -3
View File
@@ -807,7 +807,7 @@ func unchangedFile(r scanRec) (fileRec, bool, error) {
} }
if !fi.Mode().IsRegular() || fi.Size() != r.size || if !fi.Mode().IsRegular() || fi.Size() != r.size ||
fi.ModTime().Unix() > r.mtime { fi.ModTime().UnixNano() > r.mtime {
return fileRec{}, false, nil return fileRec{}, false, nil
} }
@@ -966,7 +966,7 @@ func seedRoot(ctx context.Context, root string,
sendEvent(ctx, events, walkEvent{rec: fileRec{ sendEvent(ctx, events, walkEvent{rec: fileRec{
path: root, path: root,
size: fi.Size(), size: fi.Size(),
mtime: fi.ModTime().Unix(), mtime: fi.ModTime().UnixNano(),
dev: dev, dev: dev,
ino: ino, ino: ino,
}}) }})
@@ -1124,7 +1124,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
sendEvent(ctx, events, walkEvent{rec: fileRec{ sendEvent(ctx, events, walkEvent{rec: fileRec{
path: p, path: p,
size: info.Size(), size: info.Size(),
mtime: info.ModTime().Unix(), mtime: info.ModTime().UnixNano(),
dev: dev, dev: dev,
ino: ino, ino: ino,
}}) }})
+48 -3
View File
@@ -1285,8 +1285,53 @@ func TestSyncScanMtimeBump(t *testing.T) {
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st) t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
} }
if r := recordByPath(t, dbRecords(t, db), a); 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.Unix()) 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)) 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) err := os.Chtimes(p, mt, mt)
if err != nil { if err != nil {