File Coverage

blib/lib/File/Move/Undoable.pm
Criterion Covered Total %
statement 58 67 86.5
branch 22 40 55.0
condition 7 13 53.8
subroutine 9 9 100.0
pod 1 1 100.0
total 97 130 74.6


line stmt bran cond sub pod time code
1             package File::Move::Undoable;
2              
3             our $DATE = '2016-06-10'; # DATE
4             our $VERSION = '0.08'; # VERSION
5              
6 1     1   5348 use 5.010001;
  1         2  
7 1     1   3 use strict;
  1         1  
  1         13  
8 1     1   2 use warnings;
  1         1  
  1         21  
9 1     1   689 use Log::Any::IfLOG '$log';
  1         9  
  1         4  
10              
11 1     1   463 use File::MoreUtil qw(file_exists l_abs_path);
  1         311  
  1         45  
12 1     1   417 use File::Trash::Undoable;
  1         6850  
  1         48  
13 1     1   439 use IPC::System::Options 'system', -log=>1;
  1         2801  
  1         6  
14 1     1   46 use Proc::ChildError qw(explain_child_error);
  1         1  
  1         634  
15              
16             our %SPEC;
17              
18             $SPEC{mv} = {
19             v => 1.1,
20             summary => 'Move file/directory using rename/rsync, with undo support',
21             description => <<'_',
22              
23             If moving to the same filesystem, will move using `rename()`. On undo will
24             restore the old name.
25              
26             If moving to a different filesystem, will copy to `target` using `rsync` and
27             then trash `source`. On undo, will trash `target` and restore `source` from
28             trash.
29              
30             Fixed state: `source` does not exist and `target` exists. Content or sizes are
31             not checked; only existence.
32              
33             Fixable state: `source` exists and `target` doesn't exist.
34              
35             Unfixable state: `source` does not exist, or both `source` and `target` exist
36             (unless we are moving to a different filesystem, in which it means an
37             interrupted transfer and thus fixable).
38              
39             _
40             args => {
41             source => {
42             schema => 'str*',
43             req => 1,
44             pos => 0,
45             },
46             target => {
47             schema => 'str*',
48             summary => 'Target location',
49             description => <<'_',
50              
51             Note that to avoid ambiguity, you must specify full location instead of just
52             directory name. For example: mv(source=>'/dir', target=>'/a') will move /dir to
53             /a and mv(source=>'/dir', target=>'/a/dir') will move /dir to /a/dir.
54              
55             _
56             req => 1,
57             pos => 1,
58             },
59             rsync_opts => {
60             schema => [array => {of=>'str*', default=>['-a']}],
61             summary => 'Rsync options',
62             description => <<'_',
63              
64             By default, `-a` is used. You should not use rsync options that modify or
65             destroy source, like `--remove-source-files` as it will make recovery of
66             interrupted move impossible.
67              
68             _
69             },
70             },
71             features => {
72             tx => {v=>2},
73             idempotent => 1,
74             },
75             deps => {
76             prog => 'rsync',
77             },
78             };
79             sub mv {
80 45     45 1 3636424 require Sys::Filesystem::MountPoint; # a bit heavy
81              
82 45         242 my %args = @_;
83              
84             # TMP, schema
85 45   50     237 my $tx_action = $args{-tx_action} // '';
86             my $taid = $args{-tx_action_id}
87 45 100       167 or return [412, "Please specify -tx_action_id"];
88 42         121 my $dry_run = $args{-dry_run};
89 42         85 my $source = $args{source};
90 42 50       126 defined($source) or return [400, "Please specify source"];
91 42         92 my $target = $args{target};
92 42 50       131 defined($target) or return [400, "Please specify target"];
93 42   50     136 my $rsync_opts = $args{rsync_opts} // ['-a'];
94 42 50       134 $rsync_opts = [$rsync_opts] unless ref($rsync_opts) eq 'ARRAY';
95              
96 42         172 my $se = file_exists($source);
97 42         813 my $te = file_exists($target);
98 42 50       652 my $asource = l_abs_path($source) or return [400, "Invalid path $source"];
99 42 50       1207 my $atarget = l_abs_path($target) or return [400, "Invalid path $target"];
100             # since path_to_mount_point resolves symlink (sigh), we need to remove the
101             # leaf. otherwise: /mnt/sym -> / will cause mount point to become / instead
102             # of /mnt
103 42         1058 for ($asource, $atarget) {
104 84 50       715 s!/[^/]+\z!! if (-l $_);
105             }
106 42         309 my $mpsource = Sys::Filesystem::MountPoint::path_to_mount_point($asource);
107 42         12295 my $mptarget = Sys::Filesystem::MountPoint::path_to_mount_point($atarget);
108 42         11915 my $same_fs = $mpsource eq $mptarget;
109 42 50       116 if ($same_fs) {
110 42         333 $log->tracef("Source %s & target %s are on the same filesystem (%s)",
111             $source, $target, $mpsource);
112             } else {
113 0         0 $log->tracef("Source %s and target %s are on different filesystems ".
114             "(%s and %s)", $source, $target, $mpsource, $mptarget);
115             }
116              
117 42 100       228 if ($tx_action eq 'check_state') {
    50          
118 23 100 100     184 return [304, "Source $source already does not exist and ".
119             "target $target exists"] if !$se && $te;
120 20 100       80 return [412, "Source $source does not exist"] unless $se;
121 19 50 33     70 return [412, "Target $target already exists"] if $te && $same_fs;
122              
123 19         29 my @undo;
124 19 50 33     134 if ($te || !$same_fs) {
125 0         0 unshift @undo, (
126             ["File::Trash::Undoable::trash" =>
127             {path=>$target, suffix=>substr($taid,0,8)}],
128             ["File::Trash::Undoable::untrash" =>
129             {path=>$source, suffix=>substr($taid,0,8)}],
130             );
131             } else {
132 19         109 unshift @undo, (
133             [mv => {source=>$target, target=>$source}],
134             );
135             }
136              
137 19 0       71 $log->info("(DRY) ".($te ? "Continue moving" : "Moving").
    50          
138             " $source -> $target ...") if $dry_run;
139 19 50       721 return [200, "$source needs to be ".
140             ($te ? "continued to be moved":"moved")." to $target",
141             undef, {undo_actions=>\@undo}];
142              
143             } elsif ($tx_action eq 'fix_state') {
144 19 50       55 if ($same_fs) {
145 19         105 $log->infof("Renaming %s -> %s ...", $source, $target);
146 19 50       1098 if (rename $source, $target) {
147 19         664 return [200, "OK"];
148             } else {
149 0           return [500, "Can't rename: $!"];
150             }
151             } else {
152 0           my @cmd = ("rsync", @$rsync_opts, "$source/", "$target/");
153 0           $log->infof("Rsync-ing %s -> %s ...", $source, $target);
154 0           system @cmd;
155 0 0         return [500, "rsync: ".explain_child_error($?)] if $?;
156 0           return File::Trash::Undoable::trash(
157             -tx_action=>'fix_state',
158             path=>$source, suffix=>substr($taid,0,8));
159             }
160             }
161 0           [400, "Invalid -tx_action"];
162             }
163              
164             1;
165             # ABSTRACT: Move file/directory using rename/rsync, with undo support
166              
167             __END__
168              
169             =pod
170              
171             =encoding UTF-8
172              
173             =head1 NAME
174              
175             File::Move::Undoable - Move file/directory using rename/rsync, with undo support
176              
177             =head1 VERSION
178              
179             This document describes version 0.08 of File::Move::Undoable (from Perl distribution File-Move-Undoable), released on 2016-06-10.
180              
181             =head1 FUNCTIONS
182              
183              
184             =head2 mv(%args) -> [status, msg, result, meta]
185              
186             Move file/directory using rename/rsync, with undo support.
187              
188             If moving to the same filesystem, will move using C<rename()>. On undo will
189             restore the old name.
190              
191             If moving to a different filesystem, will copy to C<target> using C<rsync> and
192             then trash C<source>. On undo, will trash C<target> and restore C<source> from
193             trash.
194              
195             Fixed state: C<source> does not exist and C<target> exists. Content or sizes are
196             not checked; only existence.
197              
198             Fixable state: C<source> exists and C<target> doesn't exist.
199              
200             Unfixable state: C<source> does not exist, or both C<source> and C<target> exist
201             (unless we are moving to a different filesystem, in which it means an
202             interrupted transfer and thus fixable).
203              
204             This function is not exported.
205              
206             This function is idempotent (repeated invocations with same arguments has the same effect as single invocation). This function supports transactions.
207              
208              
209             Arguments ('*' denotes required arguments):
210              
211             =over 4
212              
213             =item * B<rsync_opts> => I<array[str]> (default: ["-a"])
214              
215             Rsync options.
216              
217             By default, C<-a> is used. You should not use rsync options that modify or
218             destroy source, like C<--remove-source-files> as it will make recovery of
219             interrupted move impossible.
220              
221             =item * B<source>* => I<str>
222              
223             =item * B<target>* => I<str>
224              
225             Target location.
226              
227             Note that to avoid ambiguity, you must specify full location instead of just
228             directory name. For example: mv(source=>'/dir', target=>'/a') will move /dir to
229             /a and mv(source=>'/dir', target=>'/a/dir') will move /dir to /a/dir.
230              
231             =back
232              
233             Special arguments:
234              
235             =over 4
236              
237             =item * B<-tx_action> => I<str>
238              
239             For more information on transaction, see L<Rinci::Transaction>.
240              
241             =item * B<-tx_action_id> => I<str>
242              
243             For more information on transaction, see L<Rinci::Transaction>.
244              
245             =item * B<-tx_recovery> => I<str>
246              
247             For more information on transaction, see L<Rinci::Transaction>.
248              
249             =item * B<-tx_rollback> => I<str>
250              
251             For more information on transaction, see L<Rinci::Transaction>.
252              
253             =item * B<-tx_v> => I<str>
254              
255             For more information on transaction, see L<Rinci::Transaction>.
256              
257             =back
258              
259             Returns an enveloped result (an array).
260              
261             First element (status) is an integer containing HTTP status code
262             (200 means OK, 4xx caller error, 5xx function error). Second element
263             (msg) is a string containing error message, or 'OK' if status is
264             200. Third element (result) is optional, the actual result. Fourth
265             element (meta) is called result metadata and is optional, a hash
266             that contains extra information.
267              
268             Return value: (any)
269              
270             =head1 FAQ
271              
272             =head2 Why do you use rsync? Why not, say, File::Copy::Recursive?
273              
274             With C<rsync>, we can continue interrupted transfer. We need this ability for
275             recovery. Also, C<rsync> can handle hardlinks and preservation of ownership,
276             something which L<File::Copy::Recursive> currently does not do. And, being
277             implemented in C, it might be faster when processing large files/trees.
278              
279             =head1 HOMEPAGE
280              
281             Please visit the project's homepage at L<https://metacpan.org/release/File-Move-Undoable>.
282              
283             =head1 SOURCE
284              
285             Source repository is at L<https://github.com/perlancar/perl-File-Move-Undoable>.
286              
287             =head1 BUGS
288              
289             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=File-Move-Undoable>
290              
291             When submitting a bug or request, please include a test-file or a
292             patch to an existing test-file that illustrates the bug or desired
293             feature.
294              
295             =head1 SEE ALSO
296              
297             L<Setup>
298              
299             L<Rinci::Transaction>
300              
301             =head1 AUTHOR
302              
303             perlancar <perlancar@cpan.org>
304              
305             =head1 COPYRIGHT AND LICENSE
306              
307             This software is copyright (c) 2016 by perlancar@cpan.org.
308              
309             This is free software; you can redistribute it and/or modify it under
310             the same terms as the Perl 5 programming language system itself.
311              
312             =cut