NAME
App::FileSync::Client - sync one repo, and the client-side repo commands
SYNOPSIS
my $config = App::FileSync::Config::load_client();
my $client = App::FileSync::Client->new(config => $config, name => 'docs', opts => { verbose => 1 });
my $result = $client->sync;
exit $result->{exit};
DESCRIPTION
A sync is one session with the server: hello, manifest, plan, then the transfers the plan lists, then done. The client keeps under ~/.filesync (or FILESYNC_HOME):
cache/NAME.json the base: what the last successful sync left
logs/NAME.jsonl one line per attempt, for "filesync log"
locks/NAME.lock held for the whole run
trash/repos/NAME/Y/M/D/ files the sync deleted or replaced locally
Exit codes: 0 ok, 1 error, 2 another run holds the lock, 3 finished with warnings (conflicts or skipped paths).
METHODS
new
Purpose: a client for one repo of a client configuration.
Args: config (from "load_client" in App::FileSync::Config), name (the repo's key in it), optional opts (dry_run, force, no_prune, no_delta, verbose, quiet), logger, settings (process settings, read lazily otherwise), language.
Returns: the client.
Side Effects: builds a logger from the settings when none is given.
Usage:
my $client = App::FileSync::Client->new(config => $config, name => 'docs');
EXAMPLE
my $client = App::FileSync::Client->new(
config => $config, name => 'docs', opts => { dry_run => 1 },
);
API SPECIFICATION
INPUT
{
config => { type => 'hashref' },
name => { type => 'string', min => 1, max => 64 },
opts => { type => 'hashref', optional => 1 },
logger => { type => 'object', optional => 1 },
settings => { type => 'hashref', optional => 1 },
language => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'object', isa => 'App::FileSync::Client' }
MESSAGES
Key Meaning Resolution
client.no_config no configuration was given pass config
client.bad_name the repo name is not a plain id rename the repo in config.json
FORMAL SPECIFICATION
pre config ≠ ⊥ ∧ name ∈ Id
post self.name = name ∧ self.opts = opts ∨ ∅
sync
Purpose: run one sync session for the repo.
Args: none (see opts in "new").
Returns: { repo, ok, exit, summary, warnings, stats }; with dry_run, also dry_run => 1 and the planned actions. A lock refusal (here or on the server) returns ok => 0, exit => 2, locked => 1 rather than raising: cron starting a run while the last one is finishing is normal.
Side Effects: changes the local tree and the master; updates the cache; prunes this repo's local trash (unless no_prune); appends to the run log, whatever the outcome.
Usage:
my $result = App::FileSync::Client->new(%args)->sync;
EXAMPLE
my $result = $client->sync;
printf "pulled %d, pushed %d\n", @{ $result->{summary} }{qw(pulled pushed)};
print "warning: $_\n" for @{ $result->{warnings} };
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{
type => 'hashref',
schema => {
repo => { type => 'string' },
ok => { type => 'integer', memberof => [ 0, 1 ] },
exit => { type => 'integer', memberof => [ 0, 2, 3 ] },
summary => { type => 'hashref', optional => 1 },
warnings => { type => 'arrayref', optional => 1 },
},
}
MESSAGES
Key Meaning Resolution
client.no_repo the repo is not in the configuration filesync init
client.bad_root the sync root is not a directory fix root in config.json
client.no_marker the root has no .filesync-root marker mount the drive, or create the marker
client.no_client_id the configuration has no client_id filesync init
client.refused the server refused the session read the reason
client.req.* the server rejected a step read the reason
client.hash_mismatch a transfer arrived corrupted rerun the sync
FORMAL SPECIFICATION
ΔFS
sync = ⟨exit ↦ 2⟩ if lock held elsewhere
sync = ⟨exit ↦ 0 | 3⟩ ∧ local' ≡ master' if the session completes
sync raises ∧ base' = base otherwise
PSEUDOCODE
look up the repo
run the session (lock, transport, protocol)
record the attempt in the run log, success or failure
log a summary; return the result, or re-raise the error
log
Purpose: recent run attempts from the local run log.
Args: optional limit (default 20), since and until (YYYY-MM-DD or epoch, inclusive), exits (an exit code or arrayref of them), warnings (only runs that recorded a warning, conflict or skipped path). All filters apply before the limit.
Returns: { repo, path, runs } (runs oldest first), plus the filters used.
Side Effects: reads the log. A torn final line from a killed run is skipped.
Usage:
my $log = $client->log(limit => 5, exits => [ 1, 2 ]);
EXAMPLE
for my $run (@{ $client->log(since => '2026-10-01')->{runs} }) {
print "$run->{ts} exit $run->{exit}\n";
}
API SPECIFICATION
INPUT
{
limit => { type => 'integer', optional => 1 },
since => { type => 'string', optional => 1 },
until => { type => 'string', optional => 1 },
exits => { type => 'arrayref', optional => 1 }, # a lone code is wrapped
warnings => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'hashref', schema => { repo => { type => 'string' }, path => { type => 'string' }, runs => { type => 'arrayref' } } }
MESSAGES
Key Meaning Resolution
when.bad since/until is malformed use YYYY-MM-DD or an epoch
io.read the log cannot be read check permissions
FORMAL SPECIFICATION
log = last limit ⟨ r ∈ runs | since ≤ r.ts ≤ until ∧ (exits = ∅ ∨ r.exit ∈ exits) ∧ (¬warnings ∨ has_warnings(r)) ⟩
PSEUDOCODE
parse the filters
read each JSON line of the log, skipping torn ones
keep the runs that pass every filter
keep the newest limit; report the filters used
verify
Purpose: compare the local tree with the base the last successful sync recorded. Drift means the tree changed without being synced.
Args: optional deep (hash every file rather than trusting size and mtime).
Returns: the App::FileSync::Verify report, plus repo.
Side Effects: reads the tree; logs a summary. Works offline: the ignore list is the one cached by the last sync.
Usage:
my $report = $client->verify(deep => 1);
EXAMPLE
my $report = $client->verify;
print $report->{ok} ? "clean\n" : scalar(@{ $report->{modified} }) . " modified\n";
API SPECIFICATION
INPUT
{ deep => { type => 'boolean', optional => 1 } }
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
client.bad_root the sync root is not a directory fix root in config.json
client.no_marker the root has no .filesync-root marker mount the drive
FORMAL SPECIFICATION
verify = Verify(root, cache.entries, Ignore(cache.ignore ∪ {snapshots}))
restore
Purpose: put a path back from this repo's local trash. The newest stored version wins; list shows them all instead.
Args: path, optional to (a directory; default the sync root), list.
Versions are searched in this repo's trash first, then in the shared trash that releases before 0.001.1 used, so files trashed before an upgrade can still be restored.
Returns: with list, { repo, path, versions }; otherwise { repo, path, from, to }.
Side Effects: copies the version out; the trash entry is kept.
Usage:
$client->restore(path => 'notes/a.txt', to => '/tmp/out');
EXAMPLE
my $versions = $client->restore(path => 'a.txt', list => 1)->{versions};
print "$_->{date} $_->{size}\n" for @{$versions};
API SPECIFICATION
INPUT
{
path => { type => 'string', min => 1 },
to => { type => 'string', optional => 1 },
list => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
client.unsafe the path fails validate_relpath fix the path
client.no_versions nothing of that path is in the trash check --list
client.not_dir the target is not a directory choose another
client.exists the destination exists move it aside or use --to
FORMAL SPECIFICATION
ΔFS
pre path ∈ SafePath ∧ versions(path) ≠ ⟨⟩ ∧ dest ¬in; dom FS
post tree'(dest) = tree(head versions(path))
PSEUDOCODE
validate the path; list versions from the repo trash then the legacy trash
if listing, return them
destination := to (created) or the sync root, plus the path
refuse to overwrite; copy the newest version out
snapshot_create
Purpose: take today's snapshot of a replica.
Args: optional keep (how many snapshots to keep; default the repo's snapshot_keep, else 14; 0 keeps all).
Returns: { repo, ok, exit, keep, pruned, older, ... } with the "create" in App::FileSync::Snapshot result merged in; exit => 2 when another run holds the lock.
Side Effects: holds the repo lock; creates a snapshot; prunes old ones when a new one was made.
Usage:
my $made = $client->snapshot_create(keep => 7);
EXAMPLE
my $made = $client->snapshot_create;
print $made->{created} ? "snapshot $made->{date}\n" : "already have $made->{date}\n";
API SPECIFICATION
INPUT
{ keep => { type => 'integer', min => 0, optional => 1 } }
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
client.not_replica the repo is a client snapshots are for replicas
snapshot.* see App::FileSync::Snapshot
FORMAL SPECIFICATION
pre role = replica
created ∧ keep > 0 ⇒ #snapshots' ≤ keep
PSEUDOCODE
refuse unless the repo is a replica
take the lock, or report that another run holds it
choose the mode and the number to keep
create today's snapshot; prune if one was made
snapshot_list
Purpose: a replica's snapshots, newest first.
Args: none.
Returns: { repo, snapshots }.
Side Effects: walks every snapshot to count it.
Usage:
my $list = $client->snapshot_list;
EXAMPLE
print "$_->{date}\n" for @{ $client->snapshot_list->{snapshots} };
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{ type => 'hashref', schema => { repo => { type => 'string' }, snapshots => { type => 'arrayref' } } }
MESSAGES
Key Meaning Resolution
client.no_repo the repo is not in the config filesync init
FORMAL SPECIFICATION
snapshot_list = ⟨repo ↦ name, snapshots ↦ Snapshot::list(root)⟩
snapshot_restore
Purpose: copy a snapshot (or one path of it) back.
Args: date, optional path (one relative path; default everything) and to (default the sync root, which rolls the replica back).
Returns: the "restore" in App::FileSync::Snapshot result plus repo, ok, exit and into_root; exit => 2 when another run holds the lock.
Side Effects: holds the repo lock; replaces files under to.
Usage:
$client->snapshot_restore(date => '2026-10-20', path => 'notes');
EXAMPLE
my $done = $client->snapshot_restore(date => '2026-10-20', to => '/tmp/export');
print "$done->{files} files\n";
API SPECIFICATION
INPUT
{
date => { type => 'string', optional => 1 },
path => { type => 'string', optional => 1 },
to => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
See "restore" in App::FileSync::Snapshot.
FORMAL SPECIFICATION
ΔFS
into_root ⇔ to = root
tree'(to / p) = tree(snapshot(date) / p) for each restored p
cache_path
Documented with "log_path" below.
log_path
Purpose: where this repo's cache and run log live.
Args: none.
Returns: HOME/cache/NAME.json, HOME/logs/NAME.jsonl.
Side Effects: none.
Usage:
my $path = $client->cache_path;
EXAMPLE
print $client->log_path, "\n";
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
Key Meaning Resolution
config.no_home no home directory is known set HOME or FILESYNC_HOME
FORMAL SPECIFICATION
cache_path = home / "cache" / name ⁀ ".json"
log_path = home / "logs" / name ⁀ ".jsonl"
cache
Purpose: the cached base and last-run summary.
Args: none.
Returns: the cache hashref, always with an entries hashref; an empty cache when there is none or it is unreadable (the next sync rebuilds it).
Side Effects: reads the cache file.
Usage:
my $last = $client->cache->{last_run};
EXAMPLE
my $cache = $client->cache;
printf "%d files at the last sync\n", scalar keys %{ $cache->{entries} };
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{ type => 'hashref', schema => { entries => { type => 'hashref' } } }
MESSAGES
None; a damaged cache is treated as empty.
FORMAL SPECIFICATION
cache = json(FS(cache_path)) if that parses to an object, else ⟨entries ↦ ∅⟩
LIMITATIONS
The base is a JSON file rewritten whole after every sync; a tree of millions of paths makes that write, and the manifest it is built from, large.
logreads the whole run log; it is trimmed to 500 lines, so this stays small.Local trash written by releases before 0.001.1 sits in one shared directory. It is still searched by
restoreand pruned with the default 30 days, but arestorecan then offer another repo's file of the same relative path.A pull is verified against the hash in the plan, which the server computed when it planned. If the master changes during the session (only possible if something other than filesync writes to it), the pull fails with a hash mismatch and the next sync retries.
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.