Record each file's size on disk and print it in trees (closes #147)
check / check (push) Canceled after 0s

The files table gets a disk_size column: the file's allocated blocks,
st_blocks × 512, from the lstat the walk already does. scan writes it
with every record it writes. trees adds it up per directory and prints
it as a column after size. On a compressed or sparse file it is less
than size, so two copies of one tree can differ; each row shows the
dupe copy's.

Judgement call: a row's disk_size is the dupe copy's, not the first's.
Unchanged files keep the disk_size from their last hash: a change in
allocation that leaves size and mtime alone is not picked up.

Model: opus-5-5
This commit is contained in:
2026-10-27 18:08:27 +00:00
parent 98193b2896
commit dfbfdd0601
10 changed files with 230 additions and 88 deletions
+46 -24
View File
@@ -52,15 +52,17 @@ const workQueueDepth = 1024
// has already printed its line, so run prints nothing more.
var errInterrupted = errors.New("scan interrupted")
// fileRec carries one statted file between the scan phases. dev and
// ino identify the underlying inode so hard-linked paths can share
// one read; both are zero when the platform exposes no inode.
// fileRec carries one statted file between the scan phases. diskSize is
// the bytes the file takes on disk. dev and ino identify the underlying
// inode so hard-linked paths can share one read; both are zero when the
// platform exposes no inode.
type fileRec struct {
path string
size int64
mtime time.Time
dev uint64
ino uint64
path string
size int64
diskSize int64
mtime time.Time
dev uint64
ino uint64
}
// fileMeta is the in-memory index entry for one existing database
@@ -695,12 +697,13 @@ func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
s.resolve(rec.path)
s.batch = append(s.batch, scanRec{
size: rec.size,
mtime: rec.mtime,
head: r.head,
tail: r.tail,
content: r.content,
path: rec.path,
size: rec.size,
diskSize: rec.diskSize,
mtime: rec.mtime,
head: r.head,
tail: r.tail,
content: r.content,
path: rec.path,
})
}
@@ -961,11 +964,12 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
dev, ino := inodeOfInfo(fi)
rec := fileRec{
path: root,
size: fi.Size(),
mtime: fi.ModTime(),
dev: dev,
ino: ino,
path: root,
size: fi.Size(),
diskSize: diskSizeOfInfo(fi),
mtime: fi.ModTime(),
dev: dev,
ino: ino,
}
if !isDatabaseFile(rec, rules) {
@@ -1145,11 +1149,12 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
dev, ino := inodeOfInfo(info)
rec := fileRec{
path: p,
size: info.Size(),
mtime: info.ModTime(),
dev: dev,
ino: ino,
path: p,
size: info.Size(),
diskSize: diskSizeOfInfo(info),
mtime: info.ModTime(),
dev: dev,
ino: ino,
}
if !isDatabaseFile(rec, rules) {
@@ -1241,6 +1246,23 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
return statDev(st), st.Ino
}
// statBlockSize is the unit of st_blocks in an lstat result on Linux and
// macOS, whatever the filesystem's own block size.
const statBlockSize = 512
// diskSizeOfInfo returns the bytes a file takes on disk: its allocated
// blocks, st_blocks × 512. On a compressed or sparse file it is less
// than the file's size. It is 0 when the platform exposes no block
// count.
func diskSizeOfInfo(fi fs.FileInfo) int64 {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0
}
return st.Blocks * statBlockSize
}
// hashResult carries the hashes computed for one inode run (or the
// error that prevented computing them) from the pool's workers to the
// hash phase. Its run leaves out the paths found removed.