| File: | blib/lib/App/FileSync/Merge.pm |
| Coverage: | 96.4% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Merge; | |||||
| 2 | ||||||
| 3 | # The three-way merge: given what the client had at the last sync (the base), | |||||
| 4 | # what it has now, and what the master has now, decide what moves where. | |||||
| 5 | # Pure functions of their arguments; nothing here touches the disk. | |||||
| 6 | ||||||
| 7 | 302 302 302 | 67002 250 3912 | use App::FileSync::Platform; | |||
| 8 | ||||||
| 9 | 302 302 302 | 506 244 2214 | use strict; | |||
| 10 | 302 302 302 | 466 251 5799 | use warnings; | |||
| 11 | 302 302 302 | 706 6361 635 | use autodie qw(:all); | |||
| 12 | ||||||
| 13 | 302 302 302 | 646768 30125 974 | use Sub::Private { mode => 'enforce' }; | |||
| 14 | ||||||
| 15 | 302 302 302 | 30474 258 6069 | use App::FileSync qw(conflict_name); | |||
| 16 | 302 302 302 | 700 257 5427 | use App::FileSync::Args qw(named); | |||
| 17 | 302 | 15379 | use App::FileSync::Constants qw( | |||
| 18 | $ROLE_CLIENT $ROLE_REPLICA @ROLES $TYPE_FILE $TYPE_DIR | |||||
| 19 | $MAX_DELETES $MAX_DELETE_PERCENT $MTIME_TOLERANCE | |||||
| 20 | 302 302 | 524 262 | ); | |||
| 21 | 302 302 302 | 603 278 4439 | use App::FileSync::I18N qw(message); | |||
| 22 | ||||||
| 23 | 302 302 302 | 540 243 951 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 24 | ||||||
| 25 | # The comparisons below run several times per path in the tree, so they are | |||||
| 26 | # lexical code references rather than wrapped private subs; see | |||||
| 27 | # App::FileSync/LIMITATIONS. | |||||
| 28 | ||||||
| 29 | # Entry type, treating a missing "t" as a file. | |||||
| 30 | my $type = sub { | |||||
| 31 | my ($entry) = @_; | |||||
| 32 | return ($entry && defined $entry->{t} && length $entry->{t}) ? $entry->{t} : $TYPE_FILE; | |||||
| 33 | }; | |||||
| 34 | ||||||
| 35 | # Two entries describe the same content: both absent, both directories, or | |||||
| 36 | # both files with the same hash. | |||||
| 37 | my $same = sub { | |||||
| 38 | my ($left, $right) = @_; | |||||
| 39 | ||||||
| 40 | return 1 unless $left || $right; | |||||
| 41 | return 0 unless $left && $right; | |||||
| 42 | ||||||
| 43 | my $left_type = $type->($left); | |||||
| 44 | return 0 unless $left_type eq $type->($right); | |||||
| 45 | return 1 if $left_type eq $TYPE_DIR; | |||||
| 46 | ||||||
| 47 | return (defined $left->{h} && defined $right->{h} && $left->{h} eq $right->{h}) ? 1 : 0; | |||||
| 48 | }; | |||||
| 49 | ||||||
| 50 | # A pull or push action for one path, carrying the hash the receiver must | |||||
| 51 | # verify against. | |||||
| 52 | my $transfer = sub { | |||||
| 53 | my ($verb, $path, $entry) = @_; | |||||
| 54 | return { | |||||
| 55 | a => $verb, | |||||
| 56 | p => $path, | |||||
| 57 | t => $type->($entry), | |||||
| 58 | (defined $entry->{h} ? (h => $entry->{h}) : ()), | |||||
| 59 | }; | |||||
| 60 | }; | |||||
| 61 | ||||||
| 62 | =encoding utf8 | |||||
| 63 | ||||||
| 64 - 169 | =head1 NAME
App::FileSync::Merge - three-way merge of base, client and master manifests
=head1 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),
);
=head1 FUNCTIONS
=head2 plan
Purpose: decide, path by path, what a session must do.
Args: C<base>, C<client>, C<master> (manifests: path =E<gt> entry, see
L<App::FileSync::Scan>), C<client_id>, C<role> (C<client> or C<replica>),
C<now> (the epoch used in conflict-copy names).
Returns: a hashref C<{ actions, warnings, deletions, conflicts }>. Actions are
hashes with C<a> (pull, push, delete_local, delete_remote or conflict) and
C<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);
=head3 EXAMPLE
my $plan = App::FileSync::Merge::plan(
base => {},
client => { 'a.txt' => { t => 'f', h => 'aa' } },
master => {},
);
print $plan->{actions}[0]{a}, "\n"; # push
=head3 API SPECIFICATION
=head4 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 },
}
=head4 OUTPUT
{
type => 'hashref',
schema => {
actions => { type => 'arrayref' },
warnings => { type => 'arrayref' },
deletions => { type => 'integer', min => 0 },
conflicts => { type => 'integer', min => 0 },
},
}
=head3 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
=head3 FORMAL SPECIFICATION
B, A, M : PATH ⸠ENTRY (base, client, master)
â p â dom B ⪠dom A ⪠dom M â¢
p â dom A â§ p â dom M â no action
p â dom A â§ same(M p, B p) â delete_remote p
p â dom A ⧠¬same(M p, B p) â pull p
p â dom M â§ same(A p, B p) â delete_local p
p â 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}
=head3 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
=cut | |||||
| 170 | ||||||
| 171 | sub plan { | |||||
| 172 | 192 | 87781 | my $args = named(\@_, { | |||
| 173 | base => { type => 'hashref', optional => 1 }, | |||||
| 174 | client => { type => 'hashref', optional => 1 }, | |||||
| 175 | master => { type => 'hashref', optional => 1 }, | |||||
| 176 | client_id => { type => 'string', optional => 1 }, | |||||
| 177 | role => { type => 'string', memberof => [@ROLES], optional => 1 }, | |||||
| 178 | now => { type => 'number', optional => 1 }, | |||||
| 179 | }, 'Merge::plan'); | |||||
| 180 | ||||||
| 181 | 192 | 719 | my $base = $args->{base} || {}; | |||
| 182 | 192 | 356 | my $client = $args->{client} || {}; | |||
| 183 | 192 | 353 | my $master = $args->{master} || {}; | |||
| 184 | 192 | 452 | my $client_id = defined $args->{client_id} ? $args->{client_id} : 'unknown'; | |||
| 185 | 192 | 376 | my $now = defined $args->{now} ? $args->{now} : time; | |||
| 186 | ||||||
| 187 | return _replica_plan($base, $client, $master) | |||||
| 188 | 192 | 589 | if ($args->{role} || $ROLE_CLIENT) eq $ROLE_REPLICA; | |||
| 189 | ||||||
| 190 | 163 | 629 | my (@actions, @warnings); | |||
| 191 | 163 163 163 163 | 172 275 208 395 | my %paths = (%{$base}, %{$client}, %{$master}); | |||
| 192 | ||||||
| 193 | 163 | 427 | for my $path (sort keys %paths) { | |||
| 194 | 559 | 676 | my ($b, $a, $m) = ($base->{$path}, $client->{$path}, $master->{$path}); | |||
| 195 | 559 | 657 | my %who = (path => $path, client => $client_id); | |||
| 196 | ||||||
| 197 | # Gone on both sides: nothing to do, the base entry is dropped at commit. | |||||
| 198 | 559 | 759 | next unless $a || $m; | |||
| 199 | ||||||
| 200 | 558 | 1071 | if (!$a || !$m) { | |||
| 201 | # One side lacks it: an unchanged survivor follows the deletion; | |||||
| 202 | # a changed one wins, and the deletion is undone with a warning. | |||||
| 203 | 251 | 499 | my ($have, $verb, $delete, $undo) = $a | |||
| 204 | ? ($a, 'push', { a => 'delete_local', p => $path, t => $type->($a) }, 'merge.changed_deleted') | |||||
| 205 | : ($m, 'pull', { a => 'delete_remote', p => $path }, 'merge.deleted_changed'); | |||||
| 206 | ||||||
| 207 | 251 | 416 | if ($b && $same->($have, $b)) { | |||
| 208 | 44 | 66 | push @actions, $delete; | |||
| 209 | } else { | |||||
| 210 | 207 | 359 | push @actions, $transfer->($verb, $path, $have); | |||
| 211 | 207 | 413 | push @warnings, message($undo, \%who) if $b; | |||
| 212 | } | |||||
| 213 | } elsif ($same->($a, $m)) { | |||||
| 214 | 269 | 256 | next; # already converged | |||
| 215 | } elsif ($b && $same->($a, $b)) { | |||||
| 216 | 6 | 14 | push @actions, $transfer->('pull', $path, $m); | |||
| 217 | } elsif ($b && $same->($m, $b)) { | |||||
| 218 | 9 | 22 | push @actions, $transfer->('push', $path, $a); | |||
| 219 | } else { | |||||
| 220 | 23 | 44 | my $resolution = _conflict($path, $a, $m, $client_id, $now); | |||
| 221 | 23 | 48 | push @actions, $resolution; | |||
| 222 | 23 | 41 | push @warnings, _conflict_warning($resolution, \%who); | |||
| 223 | } | |||||
| 224 | } | |||||
| 225 | ||||||
| 226 | 163 | 413 | return _ordered(\@actions, \@warnings); | |||
| 227 | } | |||||
| 228 | ||||||
| 229 - 280 | =head2 check_delete_limit
Purpose: guard against a misconfigured client (or an empty mount) wiping the
master.
Args: C<deletions> (planned), C<base_size> (files known at the last sync),
C<max_deletes> (default 200; 0 disables), C<max_delete_percent> (default 10;
0 disables), C<force>.
Returns: C<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);
=head3 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); ...
=head3 API SPECIFICATION
=head4 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 },
}
=head4 OUTPUT
{ type => 'string', optional => 1 }
=head3 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
=head3 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
=cut | |||||
| 281 | ||||||
| 282 | sub check_delete_limit { | |||||
| 283 | 176 | 2692 | my $args = named(\@_, { | |||
| 284 | deletions => { type => 'integer', min => 0, optional => 1 }, | |||||
| 285 | base_size => { type => 'integer', min => 0, optional => 1 }, | |||||
| 286 | max_deletes => { type => 'integer', min => 0, optional => 1 }, | |||||
| 287 | max_delete_percent => { type => 'number', min => 0, optional => 1 }, | |||||
| 288 | force => { type => 'boolean', optional => 1 }, | |||||
| 289 | }, 'Merge::check_delete_limit'); | |||||
| 290 | ||||||
| 291 | 176 | 619 | my $deletions = $args->{deletions} || 0; | |||
| 292 | 176 | 423 | my $known = $args->{base_size} || 0; | |||
| 293 | 176 | 459 | my $max = defined $args->{max_deletes} ? $args->{max_deletes} : $MAX_DELETES; | |||
| 294 | 176 | 335 | my $percent = defined $args->{max_delete_percent} ? $args->{max_delete_percent} : $MAX_DELETE_PERCENT; | |||
| 295 | ||||||
| 296 | # Never block a single deletion just because the repo is small: 10% of | |||||
| 297 | # five files is zero, which would make the tool unusable. | |||||
| 298 | 176 | 364 | my $allowed = int($known * $percent / 100); | |||
| 299 | 176 | 300 | $allowed = 1 if $allowed < 1; | |||
| 300 | ||||||
| 301 | 176 | 612 | my %args = (count => $deletions, known => $known, max => $max, percent => $percent); | |||
| 302 | my $refusal = | |||||
| 303 | 176 | 697 | ($deletions == 0 || $args->{force}) ? undef | |||
| 304 | : ($max && $deletions > $max) ? message('merge.too_many', \%args) | |||||
| 305 | : ($percent && $known > 0 && $deletions > $allowed) ? message('merge.too_many_percent', \%args) | |||||
| 306 | : undef; | |||||
| 307 | ||||||
| 308 | 176 | 487 | return $refusal; | |||
| 309 | } | |||||
| 310 | ||||||
| 311 | # _replica_plan | |||||
| 312 | # Purpose: plan for a replica: mirror the master, never push. | |||||
| 313 | # Entry: manifests for base, replica and master. | |||||
| 314 | # Exit: returns the same shape as plan(); local edits are reported and | |||||
| 315 | # then overwritten, local extras removed. | |||||
| 316 | # Side effects: none. | |||||
| 317 | sub _replica_plan :Private { | |||||
| 318 | my ($base, $client, $master) = @_; | |||||
| 319 | ||||||
| 320 | my (@actions, @warnings); | |||||
| 321 | ||||||
| 322 | for my $path (sort keys %{$master}) { | |||||
| 323 | my $have = $client->{$path}; | |||||
| 324 | next if $have && $same->($have, $master->{$path}); | |||||
| 325 | ||||||
| 326 | push @actions, $transfer->('pull', $path, $master->{$path}); | |||||
| 327 | push @warnings, message('merge.replica_overwritten', { path => $path }) | |||||
| 328 | if $have && $base->{$path} && !$same->($have, $base->{$path}); | |||||
| 329 | } | |||||
| 330 | ||||||
| 331 | for my $path (sort grep { !$master->{$_} } keys %{$client}) { | |||||
| 332 | push @actions, { a => 'delete_local', p => $path, t => $type->($client->{$path}) }; | |||||
| 333 | push @warnings, message('merge.replica_removed', { path => $path }); | |||||
| 334 | } | |||||
| 335 | ||||||
| 336 | return _ordered(\@actions, \@warnings); | |||||
| 337 | 302 302 302 | 172737 283 4585 | } | |||
| 338 | ||||||
| 339 | # _ordered | |||||
| 340 | # Purpose: the plan result: deletions first, with counts. | |||||
| 341 | # Entry: $actions and $warnings are arrayrefs. | |||||
| 342 | # Exit: returns { actions, warnings, deletions, conflicts }. | |||||
| 343 | # Side effects: none. | |||||
| 344 | sub _ordered :Private { | |||||
| 345 | my ($actions, $warnings) = @_; | |||||
| 346 | ||||||
| 347 | # Deletions run first so a type change never tries to write over a directory. | |||||
| 348 | my @deletions = grep { $_->{a} =~ /\Adelete_/ } @{$actions}; | |||||
| 349 | my @transfers = grep { $_->{a} !~ /\Adelete_/ } @{$actions}; | |||||
| 350 | ||||||
| 351 | return { | |||||
| 352 | actions => [ @deletions, @transfers ], | |||||
| 353 | warnings => $warnings, | |||||
| 354 | deletions => scalar(@deletions), | |||||
| 355 | conflicts => scalar(grep { $_->{a} eq 'conflict' } @transfers), | |||||
| 356 | }; | |||||
| 357 | 302 302 302 | 96065 299 2626 | } | |||
| 358 | ||||||
| 359 | # _conflict | |||||
| 360 | # Purpose: resolve a path both sides changed. | |||||
| 361 | # Entry: both entries exist and differ from each other and the base. | |||||
| 362 | # Exit: returns a conflict action naming the winner and, for a losing | |||||
| 363 | # file, the name to keep it under. | |||||
| 364 | # Side effects: none. | |||||
| 365 | sub _conflict :Private { | |||||
| 366 | my ($path, $client_entry, $master_entry, $client_id, $now) = @_; | |||||
| 367 | ||||||
| 368 | my ($client_type, $master_type) = ($type->($client_entry), $type->($master_entry)); | |||||
| 369 | ||||||
| 370 | # A file beats an empty directory; between two files the newer wins, and | |||||
| 371 | # within the clock tolerance the master wins deterministically. | |||||
| 372 | my $winner = $client_type ne $TYPE_FILE ? 'remote' | |||||
| 373 | : $master_type ne $TYPE_FILE ? 'client' | |||||
| 374 | : ($client_entry->{m} || 0) > ($master_entry->{m} || 0) + $MTIME_TOLERANCE ? 'client' | |||||
| 375 | : 'remote'; | |||||
| 376 | ||||||
| 377 | my ($winner_entry, $loser) = $winner eq 'client' | |||||
| 378 | ? ($client_entry, $master_entry) | |||||
| 379 | : ($master_entry, $client_entry); | |||||
| 380 | my $loser_type = $type->($loser); | |||||
| 381 | ||||||
| 382 | return { | |||||
| 383 | a => 'conflict', | |||||
| 384 | p => $path, | |||||
| 385 | winner => $winner, | |||||
| 386 | winner_type => $type->($winner_entry), | |||||
| 387 | (defined $winner_entry->{h} ? (h => $winner_entry->{h}) : ()), | |||||
| 388 | ($loser_type eq $TYPE_FILE ? (loser_name => conflict_name($path, $client_id, $now)) : ()), | |||||
| 389 | loser_type => $loser_type, | |||||
| 390 | }; | |||||
| 391 | 302 302 302 | 94319 260 2373 | } | |||
| 392 | ||||||
| 393 | # _conflict_warning | |||||
| 394 | # Purpose: the sentence that reports a conflict. | |||||
| 395 | # Entry: $resolution is a conflict action; $who holds path and client. | |||||
| 396 | # Exit: returns the translated warning. | |||||
| 397 | # Side effects: none. | |||||
| 398 | sub _conflict_warning :Private { | |||||
| 399 | my ($resolution, $who) = @_; | |||||
| 400 | ||||||
| 401 | my $master = message('merge.master'); | |||||
| 402 | my ($kept, $other) = $resolution->{winner} eq 'client' | |||||
| 403 | ? ($who->{client}, $master) | |||||
| 404 | : ($master, $who->{client}); | |||||
| 405 | ||||||
| 406 | return message($resolution->{loser_name} ? 'merge.conflict_copy' : 'merge.conflict_dir', { | |||||
| 407 | %{$who}, | |||||
| 408 | kept => $kept, | |||||
| 409 | other => $other, | |||||
| 410 | copy => $resolution->{loser_name}, | |||||
| 411 | }); | |||||
| 412 | 302 302 302 | 81253 305 2453 | } | |||
| 413 | ||||||
| 414 | 1; | |||||
| 415 | ||||||