NAME
App::FileSync::Merge - three-way merge of base, client and master manifests
SYNOPSIS
my $plan = App::FileSync::Merge::plan(
base => $base, client => $client, master => $master,
client_id => 'laptop-1', role => 'client', now => time,
);
my $refusal = App::FileSync::Merge::check_delete_limit(
deletions => $plan->{deletions}, base_size => scalar(keys %$base),
);
FUNCTIONS
plan
Purpose: decide, path by path, what a session must do.
Args: base, client, master (manifests: path => entry, see App::FileSync::Scan), client_id, role (client or replica), now (the epoch used in conflict-copy names).
Returns: a hashref { actions, warnings, deletions, conflicts }. Actions are hashes with a (pull, push, delete_local, delete_remote or conflict) and p (the path); deletions come first, so a type change never writes over a directory. Warnings are translated sentences.
Side Effects: none.
Usage:
my $plan = App::FileSync::Merge::plan(%manifests, client_id => $id);
EXAMPLE
my $plan = App::FileSync::Merge::plan(
base => {},
client => { 'a.txt' => { t => 'f', h => 'aa' } },
master => {},
);
print $plan->{actions}[0]{a}, "\n"; # push
API SPECIFICATION
INPUT
{
base => { type => 'hashref', optional => 1 },
client => { type => 'hashref', optional => 1 },
master => { type => 'hashref', optional => 1 },
client_id => { type => 'string', optional => 1 },
role => { type => 'string', memberof => [ 'client', 'replica' ], optional => 1 },
now => { type => 'number', optional => 1 },
}
OUTPUT
{
type => 'hashref',
schema => {
actions => { type => 'arrayref' },
warnings => { type => 'arrayref' },
deletions => { type => 'integer', min => 0 },
conflicts => { type => 'integer', min => 0 },
},
}
MESSAGES
Warnings (returned, not raised):
Key Meaning
merge.deleted_changed deleted on the client, changed on the master; master copy restored
merge.changed_deleted changed on the client, deleted on the master; client copy restored
merge.conflict_copy changed on both; loser kept as a conflict copy
merge.conflict_dir changed on both; an empty directory lost
merge.replica_overwritten a replica's local edit was overwritten
merge.replica_removed a replica's extra file was removed
FORMAL SPECIFICATION
B, A, M : PATH ⇸ ENTRY (base, client, master)
∀ p ∈ dom B ∪ dom A ∪ dom M •
p ¬in; dom A ∧ p ¬in; dom M ⇒ no action
p ¬in; dom A ∧ same(M p, B p) ⇒ delete_remote p
p ¬in; dom A ∧ ¬same(M p, B p) ⇒ pull p
p ¬in; dom M ∧ same(A p, B p) ⇒ delete_local p
p ¬in; dom M ∧ ¬same(A p, B p) ⇒ push p
same(A p, M p) ⇒ no action
same(A p, B p) ⇒ pull p
same(M p, B p) ⇒ push p
otherwise ⇒ conflict p
role = replica ⇒ actions ⊆ {pull, delete_local}
PSEUDOCODE
if replica: mirror the master and return
for each path in base, client or master, sorted:
apply the table above, adding a warning where one side's change
is undone or a conflict copy is made
put deletions before transfers
count deletions and conflicts
check_delete_limit
Purpose: guard against a misconfigured client (or an empty mount) wiping the master.
Args: deletions (planned), base_size (files known at the last sync), max_deletes (default 200; 0 disables), max_delete_percent (default 10; 0 disables), force.
Returns: undef when the plan is acceptable, or a translated sentence explaining the refusal. At least one deletion is always allowed, so a small repo stays usable.
Side Effects: none.
Usage:
my $refusal = App::FileSync::Merge::check_delete_limit(%counts, force => $force);
EXAMPLE
my $why = App::FileSync::Merge::check_delete_limit(deletions => 300, base_size => 1000);
print "$why\n" if defined $why; # refusing to delete 300 files (limit is 200); ...
API SPECIFICATION
INPUT
{
deletions => { type => 'integer', min => 0, optional => 1 },
base_size => { type => 'integer', min => 0, optional => 1 },
max_deletes => { type => 'integer', min => 0, optional => 1 },
max_delete_percent => { type => 'number', min => 0, optional => 1 },
force => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
Key Meaning Resolution
merge.too_many over the absolute limit --force if intended
merge.too_many_percent over the percentage of known files --force if intended
FORMAL SPECIFICATION
allowed(n, k) ⇔ n = 0 ∨ force ∨ ((max = 0 ∨ n ≤ max) ∧ (pct = 0 ∨ k = 0 ∨ n ≤ max(1, ⌊k × pct / 100⌋)))
check(n, k) = ⊥ if allowed(n, k), reason otherwise
LIMITATIONS
Conflicts are resolved by mtime; see "LIMITATIONS" in App::FileSync.
The plan holds every path in memory three times over (the union of the three manifests). That is the price of a stateless server.
A rename is seen as a deletion plus a creation, so a renamed large file is transferred again rather than moved.
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.