File Coverage

File:blib/lib/App/FileSync/Merge.pm
Coverage:96.4%

linestmtbrancondsubtimecode
1package 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.
30my $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.
37my $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.
52my $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
171sub 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
282sub 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.
317sub _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.
344sub _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.
365sub _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.
398sub _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
4141;
415