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.