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.