Store mtime in nanoseconds so a same-second rewrite is re-hashed (closes #12)
check / check (push) Waiting to run
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
|
||||
+48
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user