NAME
App::FileSync::Scan - describe a directory tree as a manifest
SYNOPSIS
my $scan = App::FileSync::Scan::scan(root => $root, ignore => $ignore, cache => $last);
for my $path (sort keys %{ $scan->{manifest} }) { ... }
printf "%d hashed, %d reused\n", @{ $scan->{stats} }{qw(hashed cached)};
FUNCTIONS
scan
Purpose: walk a tree and describe every regular file and empty directory.
Args: root (required), ignore (an App::FileSync::Ignore), cache (a previous manifest whose entries are reused when size and mtime still match).
Returns: a hashref with three hashrefs:
manifest path => { t => 'f', s => size, m => mtime, h => sha256, mode => bits }
path => { t => 'd' } (only directories empty after ignores)
stats { files, dirs, bytes, hashed, cached, skipped }
skipped path => reason, for symlinks, special files and unreadable entries
Side Effects: reads every file that is not cached. Never writes.
Usage:
my $scan = App::FileSync::Scan::scan(root => $master, ignore => $ignore);
EXAMPLE
my $scan = App::FileSync::Scan::scan(root => '/home/foo/Documents');
print scalar(keys %{ $scan->{manifest} }), " entries\n";
API SPECIFICATION
INPUT
{
root => { type => 'string', min => 1 },
ignore => { type => 'object', can => 'match', optional => 1 },
cache => { type => 'hashref', optional => 1 },
}
OUTPUT
{
type => 'hashref',
schema => {
manifest => { type => 'hashref' },
stats => { type => 'hashref' },
skipped => { type => 'hashref' },
},
}
MESSAGES
Skip reasons (in skipped, not raised):
Key Meaning
scan.unreadable a file could not be read
scan.cannot_stat an entry vanished or cannot be examined
scan.cannot_list a directory cannot be read
scan.symlink symbolic links are not synced
scan.special devices, sockets and fifos are not synced
Key Meaning Resolution
args.invalid no root was given pass root
FORMAL SPECIFICATION
Manifest == PATH ⇸ ENTRY
scan(r, ign, c) = (M, S, K) where
dom M = { p | p ∈ files(r) ∪ emptydirs(r) ∧ ¬ ign(p) ∧ ¬ unreadable(p) }
∀ p ∈ dom M • kind p = file ⇒ M(p).h = sha256(FS(r/p))
dom K ∩ dom M = ∅
PSEUDOCODE
for each entry under root, depth first, sorted:
skip ignored paths (and everything below an ignored directory)
directory: recurse; record it only if nothing inside was recorded
regular file: reuse the cached hash if size and mtime match, else hash it
anything else: record why it was skipped
clean_stale_temps
Purpose: remove temporary files left behind by an interrupted run.
Args: root, optional max_age in seconds (default 3600).
Returns: the number of files removed.
Side Effects: deletes .filesync-tmp-* files older than max_age anywhere under root. Younger ones may belong to a run in progress and are left alone. A file that cannot be removed is skipped.
Usage:
App::FileSync::Scan::clean_stale_temps(root => $root);
EXAMPLE
my $removed = App::FileSync::Scan::clean_stale_temps(root => '/srv/filesync/master/docs', max_age => 600);
API SPECIFICATION
INPUT
{
root => { type => 'string', optional => 1 },
max_age => { type => 'integer', min => 0, optional => 1 },
}
OUTPUT
{ type => 'integer', min => 0 }
MESSAGES
None; failures to remove are not errors.
FORMAL SPECIFICATION
ΔFS
Stale == { p : dom FS | p under root ∧ prefix(TEMP, basename p) ∧ now − mtime p ≥ age }
dom FS' = dom FS \ Removable(Stale)
result = #(dom FS \ dom FS')
LIMITATIONS
The cache trusts size and mtime. A tool that rewrites a file and restores its mtime (some backup restorers do) goes unnoticed until a
verify --deep.Every run stats every file; there is no change notification. On a very large tree the walk, not the transfer, dominates a quiet run.
Directory order is sorted, which costs an O(n log n) per directory but makes manifests and logs reproducible.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.