File Coverage

blib/lib/Git/Bunch.pm
Criterion Covered Total %
statement 295 462 63.8
branch 130 256 50.7
condition 39 87 44.8
subroutine 28 34 82.3
pod 4 4 100.0
total 496 843 58.8


line stmt bran cond sub pod time code
1             package Git::Bunch;
2              
3             our $DATE = '2020-10-30'; # DATE
4             our $VERSION = '0.627'; # VERSION
5              
6 1     1   153462 use 5.010001;
  1         13  
7 1     1   5 use strict;
  1         2  
  1         20  
8 1     1   5 use warnings;
  1         2  
  1         23  
9 1     1   2237 use Log::ger;
  1         54  
  1         6  
10              
11 1     1   1356 use IPC::System::Options 'system', 'readpipe', -log=>1, -lang=>'C';
  1         4193  
  1         8  
12 1     1   91 use Cwd ();
  1         2  
  1         17  
13 1     1   5 use File::chdir;
  1         2  
  1         125  
14 1     1   9 use File::Path qw(make_path);
  1         2  
  1         51  
15 1     1   8 use List::Util qw(max);
  1         2  
  1         58  
16 1     1   9 use POSIX qw(strftime);
  1         2  
  1         9  
17 1     1   2163 use String::ShellQuote;
  1         911  
  1         887  
18              
19             require Exporter;
20             our @ISA = qw(Exporter);
21             our @EXPORT_OK = qw(check_bunch sync_bunch exec_bunch);
22              
23             our %SPEC;
24              
25             $SPEC{":package"} = {
26             v => 1.1,
27             summary => 'Manage gitbunch directory (directory which contain git repos)',
28             description => <<'_',
29              
30             A _gitbunch_ or _bunch_ directory is just a term I coined to refer to a
31             directory which contains, well, a bunch of git repositories. It can also contain
32             other stuffs like files and non-git repositories (but they must be dot-dirs).
33             Example:
34              
35             repos/ -> a gitbunch dir
36             proj1/ -> a git repo
37             proj2/ -> ditto
38             perl-Git-Bunch/ -> ditto
39             ...
40             .videos/ -> a non-git dir
41             README.txt -> file
42              
43             If you organize your data as a bunch, you can easily check the status of your
44             repositories and synchronize your data between two locations, e.g. your
45             computer's harddisk and an external/USB harddisk.
46              
47             A little bit of history: after _git_ got popular, in 2008 I started using it for
48             software projects, replacing Subversion and Bazaar. Soon, I moved everything*)
49             to git repositories: notes & writings, Emacs .org agenda files, configuration,
50             even temporary downloads/browser-saved HTML files. I put the repositories inside
51             _$HOME/repos_ and add symlinks to various places for conveniences. Thus, the
52             _$HOME/repos_ became the first bunch directory.
53              
54             *) everything except large media files (e.g. recorded videos) which I put in
55             dot-dirs inside the bunch.
56              
57             See also <prog:rsybak>, which I wrote to backup everything else.
58              
59             _
60             links => [
61             {
62             url => 'prog:rsybak',
63             },
64             {
65             url => 'http://joeyh.name/code/mr/',
66             description => <<'_',
67              
68             You probably want to use this instead. _mr_ supports other control version
69             software aside from git, doesn't restrict you to put all your repos in one
70             directory, supports more operations, and has been developed since 2007. Had I
71             known about _mr_, I probably wouldn't have started gitbunch. On the other hand,
72             gitbunch is simpler (I think), doesn't require any config file, and can
73             copy/sync files/directories not under source control. I mainly use gitbunch to
74             quickly: 1) check whether there are any of my repositories which have
75             uncommitted changes; 2) synchronize (pull/push) to other locations. I put all my
76             data in one big gitbunch directory; I find it simpler. gitbunch works for me and
77             I use it daily.
78              
79             _
80             },
81             ],
82             };
83              
84             our %common_args = (
85             source => {
86             summary => 'Directory to check',
87             schema => ['str*'],
88             req => 1,
89             pos => 0,
90             },
91             include_repos => {
92             summary => 'Specific git repos to sync, if not specified '.
93             'all repos in the bunch will be processed',
94             schema => ['array' => {
95             of => 'str*',
96             }],
97             tags => ['filter'],
98             },
99             repo => {
100             summary => 'Only process a single repo',
101             schema => 'str*',
102             tags => ['filter'],
103             },
104             # XXX option to only process a single non-git dir?
105             # XXX option to only process a single file?
106             include_repos_pat=> {
107             summary => 'Specify regex pattern of repos to include',
108             schema => ['str'],
109             tags => ['filter'],
110             },
111             exclude_repos => {
112             summary => 'Exclude some repos from processing',
113             schema => ['array*' => {of => 'str*'}],
114             tags => ['filter'],
115             },
116             exclude_non_git_dirs => {
117             summary => 'Exclude non-git dirs from processing',
118             schema => ['bool'],
119             description => <<'_',
120              
121             This only applies to and `sync_bunch` operations. Operations like `check_bunch`
122             and `exec_bunch` already ignore these and only operate on git repos.
123              
124             _
125             cmdline_aliases => {
126             include_non_git_dirs => {
127             summary => 'Alias for --no-exclude-non-git-dirs',
128             schema => ['bool*', is=>1],
129             code => sub { $_[0]{exclude_non_git_dirs} = 0 },
130             },
131             },
132             tags => ['filter'],
133             },
134             exclude_files => {
135             summary => 'Exclude files from processing',
136             schema => ['bool'],
137             description => <<'_',
138              
139             This only applies to `sync_bunch` operations. Operations like `check_bunch` and
140             `exec_bunch` already ignore these and only operate on git repos.
141              
142             _
143             cmdline_aliases => {
144             include_files => {
145             summary => 'Alias for --no-exclude-files',
146             schema => ['bool*', is=>1],
147             code => sub { $_[0]{exclude_non_git_dirs} = 0 },
148             },
149             },
150             tags => ['filter'],
151             },
152             exclude_repos_pat=> {
153             summary => 'Specify regex pattern of repos to exclude',
154             schema => ['str'],
155             tags => ['filter'],
156             },
157             min_repo_access_time => {
158             summary => 'Limit to repos that are accessed (mtime, committed, status-ed, pushed) recently',
159             description => <<'_',
160              
161             This can significantly reduce the time to process the bunch if you are only
162             interested in recent repos (which is most of the time unless you are doing a
163             full check/sync).
164              
165             _
166             schema => ['date*', 'x.perl.coerce_rules' => ['!From_float::epoch', 'From_float::epoch_always', 'From_str::natural']],
167             tags => ['filter'],
168             },
169             );
170              
171             our %sort_args = (
172             sort => {
173             summary => 'Order entries',
174             schema => ['str' => {
175             in => [qw/name -name
176             mtime -mtime
177             commit_time -commit_time
178             status_time -status_time
179             pull_time -pull_time
180             /],
181             }],
182             },
183             );
184              
185             our %target_args = (
186             target => {
187             summary => 'Destination bunch',
188             schema => ['str*'],
189             req => 1,
190             pos => 1,
191             },
192             );
193              
194             our %remote_ssh_args = (
195             ssh_user => {
196             summary => 'Remote SSH user',
197             schema => ['str*', match=>qr/\A[\w-]+\z/],
198             default => 22,
199             },
200             ssh_host => {
201             summary => 'Remote SSH host',
202             schema => ['net::hostname*'],
203             req => 1,
204             },
205             ssh_port => {
206             summary => 'Remote SSH port',
207             schema => ['net::port*'],
208             default => 22,
209             },
210             ssh_path => {
211             summary => 'Remote host path to the bunch directory',
212             schema => ['pathname*'],
213             default => 22,
214             },
215             );
216              
217             sub _check_common_args {
218 14     14   45 my ($args, $requires_target) = @_;
219 14         51 my $res;
220              
221 14 50       54 $args->{source} or return [400, "Please specify source"];
222 14         129 $args->{source} =~ s!/+$!!;
223 14         96 $res = _check_bunch_sanity(\$args->{source}, 'Source');
224 14 100       72 return $res unless $res->[0] == 200;
225              
226 10         28 my $ir = $args->{include_repos};
227 10 50 33     39 return [400, "include_repos must be an array"]
228             if defined($ir) && ref($ir) ne 'ARRAY';
229 10         22 my $irp = $args->{include_repos_pat};
230 10 50       115 if (defined $irp) {
231 0 0       0 return [400, "Invalid include_repos_pat: must be a string"]
232             if ref($irp);
233 0 0       0 return [400, "Invalid include_repos_pat: $@"]
234             if !(eval q{qr/$irp/});
235             }
236 10         30 my $er = $args->{exclude_repos};
237 10 50 33     96 return [400, "exclude_repos must be an array"]
238             if defined($er) && ref($er) ne 'ARRAY';
239 10         29 my $erp = $args->{exclude_repos_pat};
240 10 50       27 if (defined $erp) {
241 0 0       0 return [400, "Invalid exclude_repos_pat: must be a string"]
242             if ref($erp);
243 0 0       0 return [400, "Invalid exclude_repos_pat: must be a string"]
244             if !(eval q{qr/$erp/});
245             }
246              
247 10 100       29 if ($requires_target) {
248 4 50       15 $args->{target} or return [400, "Please specify target"];
249 4         19 $res = _check_bunch_sanity(\$args->{target}, 'Target', 0);
250 4 100       21 return $res unless $res->[0] == 200;
251             }
252              
253 9         40 [200];
254             }
255              
256             # return 1 if normal git repo, 2 if bare git repo, 0 if not repo
257             sub _is_repo {
258 32     32   186 my $dir = shift;
259              
260 32 100       518 return 0 unless (-d $dir);
261 28 100       498 return 1 if (-d "$dir/.git");
262 15 50 33     177 return 2 if (-d "$dir/branches") && (-f "$dir/HEAD");
263 15         56 0;
264             }
265              
266             # return true if entry should be skipped
267             sub _skip_process_entry {
268 1     1   487 use experimental 'smartmatch';
  1         3523  
  1         6  
269              
270 36     36   212 my ($e, $args, $dir, $skip_non_repo) = @_;
271              
272             # skip special files
273 36 50       220 if ($e->{name} =~ /\A(repos\.db|\.gitbunch-sync-timestamp)\z/) {
274 0         0 log_debug("Skipped $e->{name} (special files)");
275 0         0 return 1;
276             }
277              
278 36         276 my $is_repo = $e->{type} eq 'r';
279              
280 36 50       137 if (defined $args->{repo}) {
281             # avoid logging all the skipped messages if user just wants to process a
282             # single repo
283 0 0       0 return 1 unless $is_repo;
284 0 0       0 return 1 unless $args->{repo} eq $e;
285 0         0 return 0;
286             }
287              
288 36 100 100     188 if ($skip_non_repo && !$is_repo) {
289 12         130 log_debug("Skipped $e->{name} (not a git repo), ".
290             "please remove it or rename to .$e->{name}");
291 12         80 return 1;
292             }
293 24 100 66     384 if ($is_repo) {
    50 66        
    50          
294 18         102 my $ir = $args->{include_repos};
295 18 50 33     91 if ($ir && !($e->{name} ~~ @$ir)) {
296 0         0 log_debug("Skipped $e->{name} (not in include_repos)");
297 0         0 return 1;
298             }
299 18         46 my $irp = $args->{include_repos_pat};
300 18 50 33     58 if (defined($irp) && $e->{name} !~ qr/$irp/) {
301 0         0 log_debug("Skipped $e->{name} (not matched include_repos_pat)");
302 0         0 return 1;
303             }
304 18         38 my $er = $args->{exclude_repos};
305 18 50 33     51 if ($er && $e->{name} ~~ @$er) {
306 0         0 log_debug("Skipped $e->{name} (in exclude_repos)");
307 0         0 return 1;
308             }
309 18         37 my $erp = $args->{exclude_repos_pat};
310 18 50 33     50 if (defined($erp) && $e->{name} =~ qr/$erp/) {
311 0         0 log_debug("Skipped $e->{name} (not matched exclude_repos_pat)");
312 0         0 return 1;
313             }
314 18         36 my $min_rat = $args->{min_repo_access_time};
315 18 50 33     67 if ($min_rat && max(grep {defined} $e->{mtime}, $e->{commit_time}, $e->{status_time}, $e->{pull_time}) < $min_rat) {
  0         0  
316 0         0 log_debug("Skipped $e->{name} (doesn't pass min_repo_access_time)");
317 0         0 return 1;
318             }
319             } elsif ((-f $dir) && $args->{exclude_files}) {
320 0         0 log_debug("Skipped $e->{name} (exclude_files)");
321 0         0 return 1;
322             } elsif ((-d $dir) && $args->{exclude_non_git_dirs}) {
323 0         0 log_debug("Skipped $e->{name} (exclude_non_git_dirs)");
324 0         0 return 1;
325             }
326 24         113 return 0;
327             }
328              
329             sub _skip_process_repo {
330 24     24   118 my ($repo, $args, $dir) = @_;
331 24         204 _skip_process_entry($repo, $args, $dir, 1);
332             }
333              
334             sub _check_bunch_sanity {
335 18     18   69 my ($path_ref, $title, $must_exist) = @_;
336 18   50     47 $title //= "Directory";
337 18         106 $$path_ref =~ s!/+$!!;
338 18 100 100     150 if ($must_exist // 1) {
339 14 100       332 (-d $$path_ref) or return [404, "$title doesn't exist"];
340             }
341 16 100       73 _is_repo($$path_ref) and
342             return [400, "$title is probably a git repo, ".
343             "you should specify a dir *containing* ".
344             "git repos instead"];
345 13         114 [200, "OK"];
346             }
347              
348             sub _list {
349 9     9   24 my $args = shift;
350              
351 9         36 my @entries;
352 9         20 @entries = do {
353 9 50       580 opendir my ($dh), "." or die "Can't read dir '$args->{source}': $!";
354 9 100       292 map { +{name => $_} } grep { $_ ne '.' && $_ ne '..' } readdir($dh);
  36         276  
  54         293  
355             };
356 9         77 for my $e (@entries) {
357 36         435 my @st = stat $e->{name};
358 36         124 $e->{mtime} = $st[9];
359 36 100       88 if (-d _) {
360 27 100       127 if ($e->{name} =~ /\A\./) {
361 9         75 $e->{type} = 'd';
362              
363             # to save stat() call, we assume any dir that does not start
364             # with dot to be a repo
365              
366             #} elsif (-d "$e->{name}/.git") {
367             # $e->{type} = 'r';
368              
369             } else {
370 18         66 $e->{type} = 'r';
371             }
372             } else {
373 9         46 $e->{type} = 'f';
374             }
375             }
376             {
377             #last unless $sort =~ /\A-?(commit_time|status_time|pull_time)/;
378 9 50       22 last unless -f "repos.db";
  9         92  
379 0         0 require DBI;
380 0         0 my $dbh = DBI->connect("dbi:SQLite:dbname=repos.db", "", "",
381             {RaiseError=>1});
382 0         0 my $sth = $dbh->prepare("SELECT * FROM repos");
383 0         0 $sth->execute;
384 0         0 my %rows;
385 0         0 while (my $row = $sth->fetchrow_hashref) {
386 0         0 $rows{$row->{name}} = $row;
387             }
388 0         0 for my $e (@entries) {
389 0 0       0 next unless my $row = $rows{$e->{name}};
390 0         0 for (qw/commit_time status_time pull_time/) {
391 0         0 $e->{$_} = $row->{$_};
392             }
393             }
394             }
395 9         43 @entries;
396             }
397              
398             sub _sort_entries_by_recent {
399 1     1   1081 no warnings 'uninitialized';
  1         3  
  1         177  
400             sort {
401 0     0   0 my $sort_a = max($a->{commit_time}, $a->{pull_time}, $a->{status_time}, $a->{mtime});
  0         0  
402 0         0 my $sort_b = max($b->{commit_time}, $b->{pull_time}, $b->{status_time}, $b->{mtime});
403 0         0 $sort_b <=> $sort_a;
404             } @_;
405             }
406              
407             $SPEC{check_bunch} = {
408             v => 1.1,
409             summary =>
410             'Check status of git repositories inside gitbunch directory',
411             description => <<'_',
412              
413             Will perform a 'git status' for each git repositories inside the bunch and
414             report which repositories are clean/unclean.
415              
416             Will die if can't chdir into bunch or git repository.
417              
418             _
419             args => {
420             %common_args,
421             },
422             deps => {
423             all => [
424             {prog => 'git'},
425             ],
426             },
427             features => {
428             progress => 1,
429             dry_run => 1,
430             },
431             };
432             sub check_bunch {
433 1     1   8 use experimental 'smartmatch';
  1         2  
  1         4  
434              
435 8     8 1 134699 my %args = @_;
436 8         20 my $res;
437              
438 8         28 my $progress = $args{-progress};
439              
440             # XXX schema
441 8         99 $res = _check_common_args(\%args);
442 8 100       35 return $res unless $res->[0] == 200;
443 6         18 my $source = $args{source};
444              
445 6         64 log_info("Checking bunch $source ...");
446              
447 6         25 my $has_unclean;
448             my %res;
449 6         50 local $CWD = $source;
450              
451 6         405 my @entries = _list(\%args);
452              
453 6         15 my $i = 0;
454 6 50       18 $progress->pos(0) if $progress;
455 6 50       16 $progress->target(~~@entries) if $progress;
456             REPO:
457 6         21 for my $e (@entries) {
458 24         93 my $repo = $e->{name};
459 24 100       207 next REPO if _skip_process_repo($e, \%args, ".");
460 12 100       197 $CWD = $i++ ? "../$repo" : $repo;
461              
462 12 50       494 $progress->update(pos => $i,
463             message =>
464             "Checking repo $repo ...")
465             if $progress;
466              
467 12 50       30 if ($args{-dry_run}) {
468 0         0 log_info("[DRY-RUN] checking status of repo %s", $repo);
469 0         0 next REPO;
470             }
471              
472 12         36 my $output = readpipe("git status 2>&1");
473 12         80275 my $exit = $? >> 8;
474 12 100 66     346 if ($exit == 0 && $output =~ /nothing to commit/) {
475 10         150 log_info("$repo is clean");
476 10         197 $res{$repo} = [200, "Clean"];
477 10         202 next;
478             }
479              
480 2         29 $has_unclean++;
481 2 50 33     232 if ($exit == 0 && $output =~ /^\s*Unmerged paths:/m) {
    100 66        
    50 33        
    0 0        
482 0         0 log_warn("$repo needs merging");
483 0         0 $res{$repo} = [500, "Needs merging"];
484             } elsif ($exit == 0 &&
485             $output =~ /(
486             Untracked \s files
487             )/x) {
488 1         29 log_warn("$repo has untracked files");
489 1         24 $res{$repo} = [500, "Has untracked files"];
490             } elsif ($exit == 0 &&
491             $output =~ /(
492             Changes \s to \s be \s committed |
493             Changes \s not \s staged \s for \s commit |
494             Changed \s but
495             )/mx) {
496 1         27 log_warn("$repo needs commit");
497 1         15 $res{$repo} = [500, "Needs commit"];
498             } elsif ($exit == 128 && $output =~ /Not a git repository/) {
499 0         0 log_warn("$repo is not a git repo (2)");
500 0         0 $res{$repo} = [500, "Not a git repo (2)"];
501             } else {
502 0         0 log_error("Can't figure out result of 'git status' ".
503             "for repo $repo: exit=$exit, output=$output");
504 0         0 $res{$repo} = [500, "Unknown (exit=$exit, output=$output)"];
505             }
506             }
507 6 50       33 $progress->finish if $progress;
508 6 100       537 [200,
    100          
509             $has_unclean ? "Some repos unclean" : "All repos clean",
510             \%res,
511             {
512             'cmdline.result' => '', 'func.res'=>\%res,
513             'cmdline.exit_code' => $has_unclean ? 1:0,
514             }];
515             }
516              
517             $SPEC{list_bunch_contents} = {
518             v => 1.1,
519             summary =>
520             'List contents inside gitbunch directory',
521             description => <<'_',
522              
523             Will list each repo or non-repo dir/file.
524              
525             _
526             args => {
527             %common_args,
528             %sort_args,
529             detail => {
530             summary =>
531             'Show detailed record for each entry instead of just its name',
532             schema => 'bool',
533             cmdline_aliases => {l => {}},
534             },
535             },
536             features => {
537             },
538             };
539             sub list_bunch_contents {
540 1     1   772 use experimental 'smartmatch';
  1         3  
  1         4  
541              
542 0     0 1 0 my %args = @_;
543              
544             # XXX schema
545 0         0 my $res = _check_common_args(\%args);
546 0 0       0 return $res unless $res->[0] == 200;
547 0         0 my $source = $args{source};
548 0   0     0 my $sort = $args{sort} // '';
549              
550 0         0 local $CWD = $source;
551              
552 0         0 my @entries = _list(\%args);
553              
554 0 0       0 if ($sort) {
555 1     1   134 no warnings 'uninitialized';
  1         11  
  1         388  
556 0         0 my $sortsub;
557 0         0 my ($rev, $field);
558 0 0       0 if (($rev, $field) = $sort =~ /\A(-)?(mtime|commit_time|status_time|pull_time)/) {
    0          
559 0 0   0   0 $sortsub = sub { ($rev ? -1:1) * ($a->{$field} <=> $b->{$field}) };
  0         0  
560             } elsif (($rev, $field) = $sort =~ /\A(-)?(name)/) {
561 0 0   0   0 $sortsub = sub { ($rev ? -1:1) * ($a->{$field} cmp $b->{$field}) };
  0         0  
562             }
563 0         0 @entries = sort $sortsub @entries;
564             }
565             #log_trace("entries: %s", \@entries);
566              
567 0         0 my @res;
568             ENTRY:
569 0         0 for my $e (@entries) {
570 0 0       0 next ENTRY if _skip_process_entry($e, \%args, ".");
571 0         0 push @res, $e;
572             }
573              
574 0         0 my %resmeta;
575 0 0       0 if ($args{detail}) {
576 0         0 $resmeta{'table.fields'} =
577             [qw/name type mtime commit_time status_time pull_time/];
578 0         0 $resmeta{'table.field_formats'} =
579             [undef, undef, 'iso8601_datetime', 'iso8601_datetime', 'iso8601_datetime', 'iso8601_datetime'];
580             } else {
581 0         0 @res = map { $_->{name} } @res;
  0         0  
582             }
583 0         0 [200, "OK", \@res, \%resmeta];
584             }
585              
586             sub _sync_repo {
587 1     1   9 use experimental 'smartmatch';
  1         1  
  1         7  
588              
589 4     4   25 my ($src, $dest, $repo, $opts) = @_;
590 4         34 my $exit;
591              
592             my @src_branches;
593 4         0 my @dest_branches;
594 4         0 my %src_heads; # last revisions for each branch
595 4         0 my %dest_heads; # last revisions for each branch
596              
597 4         82 local $CWD = "$src/$repo";
598 4         403 @src_branches = map {(/^[* ] (.+)/, $1)[-1]} readpipe("git branch");
  5         23264  
599 4         44 $exit = $? >> 8;
600 4 50       32 if ($exit) {
601 0         0 log_error("Can't list branches on src repo $src/$repo: $exit");
602 0         0 return [500, "Can't list source branches"];
603             }
604 4         41 log_debug("Source branches: %s", \@src_branches);
605              
606 4         35 for my $branch (@src_branches) {
607 5         75 my $output = readpipe("git log -1 '$branch'");
608 5         35134 $exit = $? >> 8;
609 5 50       55 if ($exit) {
610 0         0 log_error("Can't find out head for branch $branch on src repo ".
611             "$src/$repo: $exit");
612 0         0 return [500, "Can't find out head for source branch $branch"];
613             }
614 5 50       178 $output =~ /commit (\S+)/ or do {
615 0         0 log_error("Can't recognize git log output ".
616             "(searching for commit XXX): $output");
617 0         0 return [500, "Can't recognize git log output on src: $output"];
618             };
619 5         131 $src_heads{$branch} = $1;
620             }
621 4         51 log_debug("Source branch heads: %s", \%src_heads);
622              
623 4         161 $CWD = "$dest/$repo";
624 4         328 my $is_bare = _is_repo(".") == 2;
625 4         27 @dest_branches = map {(/^[* ] (.+)/, $1)[-1]} readpipe("git branch");
  4         20025  
626 4 50       36 if ($exit) {
627 0         0 log_error("Can't list branches on dest repo $repo: $exit");
628 0         0 return [500, "Can't list branches on dest: $exit"];
629             }
630 4         55 log_debug("Dest branches: %s", \@dest_branches);
631 4         53 for my $branch (@dest_branches) {
632 4         53 my $output = readpipe("git log -1 '$branch'");
633 4         27223 $exit = $? >> 8;
634 4 50       40 if ($exit) {
635 0         0 log_error("Can't find out head for branch $branch on dest repo ".
636             "$dest/$repo: $exit");
637 0         0 return [500, "Can't find out head for dest branch $branch"];
638             }
639 4 50       138 $output =~ /commit (\S+)/ or do {
640 0         0 log_error("Can't recognize git log output ".
641             "(searching for commit XXX): $output");
642 0         0 return [500, "Can't recognize git log output on src: $output"];
643             };
644 4         99 $dest_heads{$branch} = $1;
645             }
646 4         34 log_debug("Dest branch heads: %s", \%dest_heads);
647              
648 4         28 my $output;
649             my $lock_deleted;
650 4         0 my $changed_branch;
651             BRANCH:
652 4         19 for my $branch (@src_branches) {
653             # XXX we should allow fetching tags only even if head is the same, but
654             # right now tags are not that important
655 5 100 66     113 if ($src_heads{$branch} && $dest_heads{$branch} &&
      100        
656             $src_heads{$branch} eq $dest_heads{$branch}) {
657 2         27 log_debug("Skipping branch $branch because heads are the same");
658 2         11 next BRANCH;
659             }
660 3         24 $changed_branch++;
661 3         23 if (0 && !$lock_deleted++) {
662             log_debug("Deleting locks first ...");
663             unlink "$src/$repo" .($is_bare ? "" : "/.git")."/index.lock";
664             unlink "$dest/$repo".($is_bare ? "" : "/.git")."/index.lock";
665             }
666 3 100       59 log_info("Updating branch $branch of repo $repo ...")
667             if @src_branches > 1;
668 3 50       30 if ($is_bare) {
669 0         0 $output = readpipe(
670             join("",
671             "cd '$src/$repo'; ",
672             "git push '$dest/$repo' '$branch' 2>&1",
673             ));
674             } else {
675 3 100       68 $output = readpipe(
676             join("",
677             "cd '$dest/$repo'; ",
678             ($branch ~~ @dest_branches ? "":"git branch '$branch'; "),
679             "git checkout '$branch' 2>/dev/null; ",
680             "git pull '$src/$repo' '$branch' 2>&1"
681             ));
682             }
683 3         153380 $exit = $? >> 8;
684 3 50 33     241 if ($exit == 0 && $output =~ /Already up-to-date/) {
    50 33        
    50          
685 0         0 log_debug("Branch $branch of repo $repo is up to date");
686 0         0 next BRANCH;
687             } elsif ($output =~ /^error: (.+)/m) {
688 0         0 log_error("Can't successfully git pull/push branch $branch of ".
689             "repo $repo: $1");
690 0         0 return [500, "git pull/push branch $branch failed: $1"];
691             } elsif ($exit == 0 &&
692             $output =~ /^Updating \s|
693             ^Merge \s made \s by \s recursive|
694             ^Merge \s made \s by \s the \s 'recursive'|
695             /mx) {
696 3 100       58 log_warn("Branch $branch of repo $repo updated")
697             if @src_branches > 1;
698 3 100       65 log_warn("Repo $repo updated")
699             if @src_branches == 1;
700             } else {
701 0         0 log_error(
702             "Can't recognize 'git pull/push' output for branch ".
703             "$branch of repo $repo: exit=$exit, output=$output");
704 0         0 return [500, "Can't recognize git pull/push output: $output"];
705             }
706 3         75 log_debug("Result of 'git pull/push' for branch $branch of repo ".
707             "$repo: exit=$exit, output=$output");
708              
709 3         45 $output = readpipe("cd '$dest/$repo'; ".
710             "git fetch --tags '$src/$repo' 2>&1");
711 3         65541 $exit = $? >> 8;
712 3 50       207 if ($exit != 0) {
713 0         0 log_debug("Failed fetching tags: ".
714             "$output (exit=$exit)");
715 0         0 return [500, "git fetch --tags failed: $1"];
716             }
717             }
718              
719 4 50       41 if ($opts->{delete_branch}) {
720 0         0 for my $branch (@dest_branches) {
721 0 0       0 next if $branch ~~ @src_branches;
722 0 0       0 next if $branch eq 'master'; # can't delete master branch
723 0         0 $changed_branch++;
724 0         0 log_info("Deleting branch $branch of repo $repo because ".
725             "it no longer exists in src ...");
726 0         0 system("cd '$dest/$repo' && git checkout master 2>/dev/null && ".
727             "git branch -D '$branch' 2>/dev/null");
728 0         0 $exit = $? >> 8;
729 0 0       0 log_error("Failed deleting branch $branch of repo $repo: $exit")
730             if $exit;
731             }
732             }
733              
734 4 100       25 if ($changed_branch) {
735 2         113 return [200, "OK"];
736             } else {
737 2         101 return [304, "Not modified"];
738             }
739             }
740              
741             $SPEC{sync_bunch} = {
742             v => 1.1,
743             summary =>
744             'Synchronize bunch to another bunch',
745             description => <<'_',
746              
747             For each git repository in the bunch, will perform a 'git pull/push' for each
748             branch. If repository in destination doesn't exist, it will be rsync-ed first
749             from source. When 'git pull' fails, will exit to let you fix the problem
750             manually.
751              
752             For all other non-repo file/directory, will simply synchronize by one-way rsync.
753             But, for added safety, will first check the newest mtime (mtime of the newest
754             file or subdirectory) between source and target is checked first. If target
755             contains the newer newest mtime, rsync-ing for that non-repo file/dir will be
756             aborted. Note: you can use `--skip-mtime-check` option to skip this check.
757              
758             _
759             args => {
760             %common_args,
761             %target_args,
762             delete_branch => {
763             summary => 'Whether to delete branches in dest repos '.
764             'not existing in source repos',
765             schema => ['bool' => default => 0],
766             },
767             rsync_opt_maintain_ownership => {
768             summary => 'Whether or not, when rsync-ing from source, '.
769             'we use -a (= -rlptgoD) or -rlptD (-a minus -go)',
770             schema => ['bool' => default => 0],
771             description => <<'_',
772              
773             Sometimes using -a results in failure to preserve permission modes on
774             sshfs-mounted filesystem, while -rlptD succeeds, so by default we don't maintain
775             ownership. If you need to maintain ownership (e.g. you run as root and the repos
776             are not owned by root), turn this option on.
777              
778             _
779             },
780             rsync_del => {
781             summary => 'Whether to use --del rsync option',
782             schema => 'bool',
783             description => <<'_',
784              
785             When rsync-ing non-repos, by default `--del` option is not used for more safety
786             because rsync is a one-way action. To add rsync `--del` option, enable this
787              
788             _
789             },
790             skip_mtime_check => {
791             summary => 'Whether or not, when rsync-ing non-repos, '.
792             'we check mtime first',
793             schema => ['bool'],
794             description => <<'_',
795              
796             By default when we rsync a non-repo file/dir from source to target and both
797             exist, to protect wrong direction of sync-ing we find the newest mtime in source
798             or dir (if dir, then the dir is recursively traversed to find the file/subdir
799             with the newest mtime). If target contains the newer mtime, the sync for that
800             non-repo file/dir is aborted. If you want to force the rsync anyway, use this
801             option.
802              
803             _
804             cmdline_aliases => {M=>{}},
805             },
806             create_bare_target => {
807             summary => 'Whether to create bare git repo '.
808             'when target does not exist',
809             schema => ['bool'],
810             description => <<'_',
811              
812             When target repo does not exist, gitbunch can either copy the source repo using
813             `rsync` (the default, if this setting is undefined), or it can create target
814             repo with `git init --bare` (if this setting is set to 1), or it can create
815             target repo with `git init` (if this setting is set to 0).
816              
817             Bare git repositories contain only contents of the .git folder inside the
818             directory and no working copies of your source files.
819              
820             Creating bare repos are apt for backup purposes since they are more
821             space-efficient.
822              
823             Non-repos will still be copied/rsync-ed.
824              
825             _
826             cmdline_aliases => {
827             # old name, deprecated since v0.29, remove in later releases
828             use_bare => {},
829             },
830             },
831             backup => {
832             summary => 'Whether doing backup to target',
833             schema => ['bool'],
834             description => <<'_',
835              
836             This setting lets you express that you want to perform synchronizing to a backup
837             target, and that you do not do work on the target. Thus, you do not care about
838             uncommitted or untracked files/dirs in the target repos (might happen if you
839             also do periodic copying of repos to backup using cp/rsync). When this setting
840             is turned on, the function will first do a `git clean -f -d` (to delete
841             untracked files/dirs) and then `git checkout .` (to discard all uncommitted
842             changes). This setting will also implicitly turn on `create_bare` setting
843             (unless that setting has been explicitly enabled/disabled).
844              
845             _
846             },
847             action => {
848             schema => ['str*', in=>[
849             'sync',
850             'list-source-repos',
851             ]],
852             default => 'sync',
853             },
854             },
855             deps => {
856             all => [
857             {prog => 'git'},
858             {prog => 'rsync'},
859             {prog => 'rsync-new2old'},
860             {prog => 'touch'},
861             ],
862             },
863             features => {
864             progress => 1,
865             dry_run => 1,
866             },
867             };
868             sub sync_bunch {
869 1     1   1577 use experimental 'smartmatch';
  1         2  
  1         5  
870 6     6 1 182241 require Capture::Tiny;
871 6         8044 require UUID::Random;
872 6         833 require App::reposdb;
873              
874 6         3243 my %args = @_;
875 6         16 my $res;
876              
877 6         22 my $progress = $args{-progress};
878              
879             # XXX schema
880 6         59 $res = _check_common_args(\%args, 1);
881 6 100       35 return $res unless $res->[0] == 200;
882 3   50     33 my $delete_branch = $args{delete_branch} // 0;
883 3         10 my $source = $args{source};
884 3         8 my $target = $args{target};
885 3         8 my $create_bare = $args{create_bare_target};
886 3         17 my $backup = $args{backup};
887 3   50     38 my $action = $args{action} // 'sync';
888 3         10 my $exit;
889              
890 3 50 0     13 $create_bare //= 1 if $backup;
891              
892 3         5 my $cmd;
893              
894 3 50 66     64 unless ((-d $target) || $args{-dry_run} || $action eq 'list-source-repos') {
      33        
895 1         15 log_debug("Creating target directory %s ...", $target);
896 1 50       421 make_path($target)
897             or return [500, "Can't create target directory $target: $!"];
898             }
899 3         111 $target = Cwd::abs_path($target);
900              
901 3         8 my $dbh_target;
902             $dbh_target = App::reposdb::_connect_db({
903             reposdb_path => "$target/repos.db",
904 3 50 33     93 }) unless $args{-dry_run} || $action eq 'list-source-repos';
905              
906 3 50       76635 my $_a = $args{rsync_opt_maintain_ownership} ? "aH" : "rlptDH";
907              
908 3         97 $source = Cwd::abs_path($source);
909              
910 3         72 local $CWD = $source;
911 3         315 my @entries = _list(\%args);
912 3 50       14 @entries = _sort_entries_by_recent(@entries) if $args{min_repo_access_time};
913             #log_trace("entries: %s", \@entries);
914              
915 3 50       28 $CWD = $target unless $action eq 'list-source-repos';
916              
917 3         74 my %res;
918 3         6 my $i = 0;
919 3 50       16 $progress->pos(0) if $progress;
920 3 50       13 $progress->target(~~@entries) if $progress;
921              
922 3         7 my @res;
923              
924             ENTRY:
925 3         21 for my $e (@entries) {
926 12         67 ++$i;
927 12 50       330 next ENTRY if _skip_process_entry($e, \%args, "$source/$e->{name}");
928 12         165 my $is_repo = _is_repo("$source/$e->{name}");
929              
930 12 50       57 if ($action eq 'list-source-repos') {
931 0 0       0 push @res, $e->{name} if $is_repo;
932 0         0 next ENTRY;
933             }
934              
935 12 100       46 if (!$is_repo) {
936 6         24 my $file_or_dir = $e->{name};
937 6 50       21 $progress->update(pos => $i,
938             message =>
939             "Sync-ing non-git file/directory $file_or_dir ...")
940             if $progress;
941              
942 6         19 my $prog;
943             my @extra_opts;
944 6 50 33     104 if ($args{skip_mtime_check} || $args{-dry_run}) {
945 0         0 $prog = "rsync";
946             } else {
947 6         40 $prog = "rsync-new2old";
948 6         31 push @extra_opts, "--create-target-if-not-exists";
949             }
950              
951             # just some random unique string so we can detect whether any
952             # file/dir is modified/added to target. to check files deleted in
953             # target, we use /^deleting /x
954 6         74 my $uuid = UUID::Random::generate();
955 6 50       547 my $_v = log_is_debug() ? "-v" : "";
956 6 50       51 my $del = $args{rsync_del} ? "--del" : "";
957 6 50       33 push @extra_opts, "--log-format=$uuid" unless $args{-dry_run};
958             $cmd = join(
959             "",
960             $prog,
961 6 50       137 $args{-dry_run} ? " --dry-run" : "",
    50          
962             @extra_opts ? " " . join(" ", @extra_opts) : "",
963             " -${_a}z $_v $del",
964             " --force",
965             " " . shell_quote("$source/$file_or_dir"),
966             " .",
967             );
968             my ($stdout, @result) = log_is_debug() ?
969 0     0   0 Capture::Tiny::tee_stdout (sub { system($cmd) }) :
970 6 50   6   566 Capture::Tiny::capture_stdout(sub { system($cmd) });
  6         9230  
971 6 50       554639 if ($args{-dry_run}) {
    50          
972 0         0 $res{$file_or_dir} = [304, "dry-run"];
973 0         0 next ENTRY;
974             } elsif ($result[0]) {
975 0         0 log_warn("Rsync failed, please check: $result[0]");
976 0         0 $res{$file_or_dir} = [500, "rsync failed: $result[0]"];
977             } else {
978 6 100       740 if ($stdout =~ /^(deleting |\Q$uuid\E)/m) {
979 4         87 log_warn("Non-git file/dir '$file_or_dir' updated");
980             }
981 6         125 $res{$file_or_dir} = [200, "rsync-ed"];
982             }
983 6         119 next ENTRY;
984             }
985              
986 6         31 my $repo = $e->{name};
987 6         14 my $created;
988 6 100       117 if (!(-e $repo)) {
989 2 50       26 if ($args{-dry_run}) {
990 0         0 log_warn("[DRY RUN] Copying repo '%s'", $repo);
991 0         0 next ENTRY;
992             }
993 2 50       18 if ($create_bare) {
    50          
994 0         0 log_info("Initializing target repo $repo (bare) ...");
995 0         0 $cmd = "mkdir ".shell_quote($repo)." && cd ".shell_quote($repo).
996             " && git init --bare";
997 0         0 system($cmd);
998 0         0 $exit = $? >> 8;
999 0 0       0 if ($exit) {
1000 0         0 log_warn("Git init failed, please check: $exit");
1001 0         0 $res{$repo} = [500, "git init --bare failed: $exit"];
1002 0         0 next ENTRY;
1003             }
1004 0         0 $created++;
1005             # continue to sync-ing
1006             } elsif (defined $create_bare) {
1007 0         0 log_info("Initializing target repo $repo (non-bare) ...");
1008 0         0 $cmd = "mkdir ".shell_quote($repo)." && cd ".shell_quote($repo).
1009             " && git init";
1010 0         0 system($cmd);
1011 0         0 $exit = $? >> 8;
1012 0 0       0 if ($exit) {
1013 0         0 log_warn("Git init failed, please check: $exit");
1014 0         0 $res{$repo} = [500, "git init failed: $exit"];
1015 0         0 next ENTRY;
1016             }
1017 0         0 $created++;
1018             # continue to sync-ing
1019             } else {
1020 2 50       14 $progress->update(pos => $i,
1021             message =>
1022             "Copying repo $repo ...")
1023             if $progress;
1024 2         47 $cmd = "rsync -${_a}z ".shell_quote("$source/$repo")." .";
1025 2         234 system($cmd);
1026 2         117179 $exit = $? >> 8;
1027 2 50       32 if ($exit) {
1028 0         0 log_warn("Rsync failed, please check: $exit");
1029 0         0 $res{$repo} = [500, "rsync failed: $exit"];
1030             } else {
1031 2         48 $res{$repo} = [200, "rsync-ed"];
1032             }
1033 2         74 log_warn("Repo $repo copied");
1034             # touch pull time
1035 2         146 $dbh_target->do("INSERT OR IGNORE INTO repos (name) VALUES (?)",
1036             {}, $repo);
1037 2         26167 $dbh_target->do("UPDATE repos SET pull_time=? WHERE name=?",
1038             {}, time(), $repo);
1039 2         20888 next ENTRY;
1040             }
1041             }
1042              
1043 4 50       49 $progress->update(pos => $i, message => "Sync-ing repo $repo ...")
1044             if $progress;
1045              
1046 4 50       42 if ($args{-dry_run}) {
1047 0         0 log_warn("[DRY RUN] Sync-ing repo '%s'", $repo);
1048 0         0 next ENTRY;
1049             }
1050              
1051 4 50 33     31 if ($backup && !$created) {
1052 0         0 log_debug("Discarding changes in target repo $repo ...");
1053 0         0 local $CWD = $repo;
1054 0         0 system "git clean -f -d && git checkout .";
1055             # ignore error for now, let's go ahead and sync anyway
1056             }
1057              
1058 4         65 my $res = _sync_repo(
1059             $source, $target, $repo,
1060             {delete_branch => $delete_branch},
1061             );
1062             # touch pull time
1063 4 100       491 if ($res->[0] == 200) {
1064 2         131 $dbh_target->do("INSERT OR IGNORE INTO repos (name) VALUES (?)",
1065             {}, $repo);
1066 2         1226 $dbh_target->do("UPDATE repos SET pull_time=? WHERE name=?",
1067             {}, time(), $repo);
1068             }
1069 4         15410 $res{$repo} = $res;
1070             }
1071              
1072 3 50       41 $progress->finish if $progress;
1073              
1074 3 50       23 if ($action eq 'list-source-repos') {
1075 0         0 return [200, "OK", \@res];
1076             }
1077              
1078 3         63 system "touch", "$target/.gitbunch-sync-timestamp";
1079              
1080 3         14204 [200,
1081             "OK",
1082             \%res,
1083             {"cmdline.result" => ''}];
1084             }
1085              
1086             $SPEC{exec_bunch} = {
1087             v => 1.1,
1088             summary =>
1089             'Execute a command for each repo in the bunch',
1090             description => <<'_',
1091              
1092             For each git repository in the bunch, will chdir to it and execute specified
1093             command.
1094              
1095             _
1096             args => {
1097             %common_args,
1098             command => {
1099             summary => 'Command to execute',
1100             schema => ['str*'],
1101             req => 1,
1102             pos => 1,
1103             greedy => 1,
1104             },
1105             },
1106             features => {
1107             dry_run => 1,
1108             },
1109             };
1110             sub exec_bunch {
1111 0     0 1   my %args = @_;
1112 0           my $res;
1113             my $exit;
1114              
1115             # XXX schema
1116 0           $res = _check_common_args(\%args);
1117 0 0         return $res unless $res->[0] == 200;
1118 0           my $source = $args{source};
1119 0           my $command = $args{command};
1120 0 0         defined($command) or return [400, "Please specify command"];
1121              
1122 0           local $CWD = $source;
1123 0           my %res;
1124 0           my $i = 0;
1125 0           my @entries = _list(\%args);
1126 0 0         @entries = _sort_entries_by_recent(@entries) if $args{min_repo_access_time};
1127             #log_trace("entries: %s", \@entries);
1128             REPO:
1129 0           for my $e (@entries) {
1130 0 0         next REPO if _skip_process_repo($e, \%args, ".");
1131 0           my $repo = $e->{name};
1132 0 0         $CWD = $i++ ? "../$repo" : $repo;
1133 0 0         if ($args{-dry_run}) {
1134 0           log_info("[DRY-RUN] Executing command on $repo ...");
1135 0           next REPO;
1136             }
1137 0           log_info("Executing command on $repo ...");
1138 0           system($command);
1139 0           $exit = $? >> 8;
1140 0 0         if ($exit) {
1141 0           log_warn("Command failed: $exit");
1142 0           $res{$repo} = [500, "Command failed: $exit"];
1143             } else {
1144 0           $res{$repo} = [200, "Command successful"];
1145             }
1146 0           next REPO;
1147             }
1148              
1149 0           [200,
1150             "OK",
1151             \%res,
1152             {"cmdline.result" => ''}];
1153             }
1154              
1155             1;
1156             # ABSTRACT: Manage gitbunch directory (directory which contain git repos)
1157              
1158             __END__
1159              
1160             =pod
1161              
1162             =encoding UTF-8
1163              
1164             =head1 NAME
1165              
1166             Git::Bunch - Manage gitbunch directory (directory which contain git repos)
1167              
1168             =head1 VERSION
1169              
1170             This document describes version 0.627 of Git::Bunch (from Perl distribution Git-Bunch), released on 2020-10-30.
1171              
1172             =head1 SYNOPSIS
1173              
1174             See the included L<gitbunch> script.
1175              
1176             =head1 DESCRIPTION
1177              
1178              
1179             A I<gitbunch> or I<bunch> directory is just a term I coined to refer to a
1180             directory which contains, well, a bunch of git repositories. It can also contain
1181             other stuffs like files and non-git repositories (but they must be dot-dirs).
1182             Example:
1183              
1184             repos/ -> a gitbunch dir
1185             proj1/ -> a git repo
1186             proj2/ -> ditto
1187             perl-Git-Bunch/ -> ditto
1188             ...
1189             .videos/ -> a non-git dir
1190             README.txt -> file
1191              
1192             If you organize your data as a bunch, you can easily check the status of your
1193             repositories and synchronize your data between two locations, e.g. your
1194             computer's harddisk and an external/USB harddisk.
1195              
1196             A little bit of history: after I<git> got popular, in 2008 I started using it for
1197             software projects, replacing Subversion and Bazaar. Soon, I moved everything*)
1198             to git repositories: notes & writings, Emacs .org agenda files, configuration,
1199             even temporary downloads/browser-saved HTML files. I put the repositories inside
1200             I<$HOME/repos> and add symlinks to various places for conveniences. Thus, the
1201             I<$HOME/repos> became the first bunch directory.
1202              
1203             *) everything except large media files (e.g. recorded videos) which I put in
1204             dot-dirs inside the bunch.
1205              
1206             See also L<rsybak>, which I wrote to backup everything else.
1207              
1208             =head1 FUNCTIONS
1209              
1210              
1211             =head2 check_bunch
1212              
1213             Usage:
1214              
1215             check_bunch(%args) -> [status, msg, payload, meta]
1216              
1217             Check status of git repositories inside gitbunch directory.
1218              
1219             Will perform a 'git status' for each git repositories inside the bunch and
1220             report which repositories are clean/unclean.
1221              
1222             Will die if can't chdir into bunch or git repository.
1223              
1224             This function is not exported by default, but exportable.
1225              
1226             This function supports dry-run operation.
1227              
1228              
1229             Arguments ('*' denotes required arguments):
1230              
1231             =over 4
1232              
1233             =item * B<exclude_files> => I<bool>
1234              
1235             Exclude files from processing.
1236              
1237             This only applies to C<sync_bunch> operations. Operations like C<check_bunch> and
1238             C<exec_bunch> already ignore these and only operate on git repos.
1239              
1240             =item * B<exclude_non_git_dirs> => I<bool>
1241              
1242             Exclude non-git dirs from processing.
1243              
1244             This only applies to and C<sync_bunch> operations. Operations like C<check_bunch>
1245             and C<exec_bunch> already ignore these and only operate on git repos.
1246              
1247             =item * B<exclude_repos> => I<array[str]>
1248              
1249             Exclude some repos from processing.
1250              
1251             =item * B<exclude_repos_pat> => I<str>
1252              
1253             Specify regex pattern of repos to exclude.
1254              
1255             =item * B<include_repos> => I<array[str]>
1256              
1257             Specific git repos to sync, if not specified all repos in the bunch will be processed.
1258              
1259             =item * B<include_repos_pat> => I<str>
1260              
1261             Specify regex pattern of repos to include.
1262              
1263             =item * B<min_repo_access_time> => I<date>
1264              
1265             Limit to repos that are accessed (mtime, committed, status-ed, pushed) recently.
1266              
1267             This can significantly reduce the time to process the bunch if you are only
1268             interested in recent repos (which is most of the time unless you are doing a
1269             full check/sync).
1270              
1271             =item * B<repo> => I<str>
1272              
1273             Only process a single repo.
1274              
1275             =item * B<source>* => I<str>
1276              
1277             Directory to check.
1278              
1279              
1280             =back
1281              
1282             Special arguments:
1283              
1284             =over 4
1285              
1286             =item * B<-dry_run> => I<bool>
1287              
1288             Pass -dry_run=E<gt>1 to enable simulation mode.
1289              
1290             =back
1291              
1292             Returns an enveloped result (an array).
1293              
1294             First element (status) is an integer containing HTTP status code
1295             (200 means OK, 4xx caller error, 5xx function error). Second element
1296             (msg) is a string containing error message, or 'OK' if status is
1297             200. Third element (payload) is optional, the actual result. Fourth
1298             element (meta) is called result metadata and is optional, a hash
1299             that contains extra information.
1300              
1301             Return value: (any)
1302              
1303              
1304              
1305             =head2 exec_bunch
1306              
1307             Usage:
1308              
1309             exec_bunch(%args) -> [status, msg, payload, meta]
1310              
1311             Execute a command for each repo in the bunch.
1312              
1313             For each git repository in the bunch, will chdir to it and execute specified
1314             command.
1315              
1316             This function is not exported by default, but exportable.
1317              
1318             This function supports dry-run operation.
1319              
1320              
1321             Arguments ('*' denotes required arguments):
1322              
1323             =over 4
1324              
1325             =item * B<command>* => I<str>
1326              
1327             Command to execute.
1328              
1329             =item * B<exclude_files> => I<bool>
1330              
1331             Exclude files from processing.
1332              
1333             This only applies to C<sync_bunch> operations. Operations like C<check_bunch> and
1334             C<exec_bunch> already ignore these and only operate on git repos.
1335              
1336             =item * B<exclude_non_git_dirs> => I<bool>
1337              
1338             Exclude non-git dirs from processing.
1339              
1340             This only applies to and C<sync_bunch> operations. Operations like C<check_bunch>
1341             and C<exec_bunch> already ignore these and only operate on git repos.
1342              
1343             =item * B<exclude_repos> => I<array[str]>
1344              
1345             Exclude some repos from processing.
1346              
1347             =item * B<exclude_repos_pat> => I<str>
1348              
1349             Specify regex pattern of repos to exclude.
1350              
1351             =item * B<include_repos> => I<array[str]>
1352              
1353             Specific git repos to sync, if not specified all repos in the bunch will be processed.
1354              
1355             =item * B<include_repos_pat> => I<str>
1356              
1357             Specify regex pattern of repos to include.
1358              
1359             =item * B<min_repo_access_time> => I<date>
1360              
1361             Limit to repos that are accessed (mtime, committed, status-ed, pushed) recently.
1362              
1363             This can significantly reduce the time to process the bunch if you are only
1364             interested in recent repos (which is most of the time unless you are doing a
1365             full check/sync).
1366              
1367             =item * B<repo> => I<str>
1368              
1369             Only process a single repo.
1370              
1371             =item * B<source>* => I<str>
1372              
1373             Directory to check.
1374              
1375              
1376             =back
1377              
1378             Special arguments:
1379              
1380             =over 4
1381              
1382             =item * B<-dry_run> => I<bool>
1383              
1384             Pass -dry_run=E<gt>1 to enable simulation mode.
1385              
1386             =back
1387              
1388             Returns an enveloped result (an array).
1389              
1390             First element (status) is an integer containing HTTP status code
1391             (200 means OK, 4xx caller error, 5xx function error). Second element
1392             (msg) is a string containing error message, or 'OK' if status is
1393             200. Third element (payload) is optional, the actual result. Fourth
1394             element (meta) is called result metadata and is optional, a hash
1395             that contains extra information.
1396              
1397             Return value: (any)
1398              
1399              
1400              
1401             =head2 list_bunch_contents
1402              
1403             Usage:
1404              
1405             list_bunch_contents(%args) -> [status, msg, payload, meta]
1406              
1407             List contents inside gitbunch directory.
1408              
1409             Will list each repo or non-repo dir/file.
1410              
1411             This function is not exported.
1412              
1413             Arguments ('*' denotes required arguments):
1414              
1415             =over 4
1416              
1417             =item * B<detail> => I<bool>
1418              
1419             Show detailed record for each entry instead of just its name.
1420              
1421             =item * B<exclude_files> => I<bool>
1422              
1423             Exclude files from processing.
1424              
1425             This only applies to C<sync_bunch> operations. Operations like C<check_bunch> and
1426             C<exec_bunch> already ignore these and only operate on git repos.
1427              
1428             =item * B<exclude_non_git_dirs> => I<bool>
1429              
1430             Exclude non-git dirs from processing.
1431              
1432             This only applies to and C<sync_bunch> operations. Operations like C<check_bunch>
1433             and C<exec_bunch> already ignore these and only operate on git repos.
1434              
1435             =item * B<exclude_repos> => I<array[str]>
1436              
1437             Exclude some repos from processing.
1438              
1439             =item * B<exclude_repos_pat> => I<str>
1440              
1441             Specify regex pattern of repos to exclude.
1442              
1443             =item * B<include_repos> => I<array[str]>
1444              
1445             Specific git repos to sync, if not specified all repos in the bunch will be processed.
1446              
1447             =item * B<include_repos_pat> => I<str>
1448              
1449             Specify regex pattern of repos to include.
1450              
1451             =item * B<min_repo_access_time> => I<date>
1452              
1453             Limit to repos that are accessed (mtime, committed, status-ed, pushed) recently.
1454              
1455             This can significantly reduce the time to process the bunch if you are only
1456             interested in recent repos (which is most of the time unless you are doing a
1457             full check/sync).
1458              
1459             =item * B<repo> => I<str>
1460              
1461             Only process a single repo.
1462              
1463             =item * B<sort> => I<str>
1464              
1465             Order entries.
1466              
1467             =item * B<source>* => I<str>
1468              
1469             Directory to check.
1470              
1471              
1472             =back
1473              
1474             Returns an enveloped result (an array).
1475              
1476             First element (status) is an integer containing HTTP status code
1477             (200 means OK, 4xx caller error, 5xx function error). Second element
1478             (msg) is a string containing error message, or 'OK' if status is
1479             200. Third element (payload) is optional, the actual result. Fourth
1480             element (meta) is called result metadata and is optional, a hash
1481             that contains extra information.
1482              
1483             Return value: (any)
1484              
1485              
1486              
1487             =head2 sync_bunch
1488              
1489             Usage:
1490              
1491             sync_bunch(%args) -> [status, msg, payload, meta]
1492              
1493             Synchronize bunch to another bunch.
1494              
1495             For each git repository in the bunch, will perform a 'git pull/push' for each
1496             branch. If repository in destination doesn't exist, it will be rsync-ed first
1497             from source. When 'git pull' fails, will exit to let you fix the problem
1498             manually.
1499              
1500             For all other non-repo file/directory, will simply synchronize by one-way rsync.
1501             But, for added safety, will first check the newest mtime (mtime of the newest
1502             file or subdirectory) between source and target is checked first. If target
1503             contains the newer newest mtime, rsync-ing for that non-repo file/dir will be
1504             aborted. Note: you can use C<--skip-mtime-check> option to skip this check.
1505              
1506             This function is not exported by default, but exportable.
1507              
1508             This function supports dry-run operation.
1509              
1510              
1511             Arguments ('*' denotes required arguments):
1512              
1513             =over 4
1514              
1515             =item * B<action> => I<str> (default: "sync")
1516              
1517             =item * B<backup> => I<bool>
1518              
1519             Whether doing backup to target.
1520              
1521             This setting lets you express that you want to perform synchronizing to a backup
1522             target, and that you do not do work on the target. Thus, you do not care about
1523             uncommitted or untracked files/dirs in the target repos (might happen if you
1524             also do periodic copying of repos to backup using cp/rsync). When this setting
1525             is turned on, the function will first do a C<git clean -f -d> (to delete
1526             untracked files/dirs) and then C<git checkout .> (to discard all uncommitted
1527             changes). This setting will also implicitly turn on C<create_bare> setting
1528             (unless that setting has been explicitly enabled/disabled).
1529              
1530             =item * B<create_bare_target> => I<bool>
1531              
1532             Whether to create bare git repo when target does not exist.
1533              
1534             When target repo does not exist, gitbunch can either copy the source repo using
1535             C<rsync> (the default, if this setting is undefined), or it can create target
1536             repo with C<git init --bare> (if this setting is set to 1), or it can create
1537             target repo with C<git init> (if this setting is set to 0).
1538              
1539             Bare git repositories contain only contents of the .git folder inside the
1540             directory and no working copies of your source files.
1541              
1542             Creating bare repos are apt for backup purposes since they are more
1543             space-efficient.
1544              
1545             Non-repos will still be copied/rsync-ed.
1546              
1547             =item * B<delete_branch> => I<bool> (default: 0)
1548              
1549             Whether to delete branches in dest repos not existing in source repos.
1550              
1551             =item * B<exclude_files> => I<bool>
1552              
1553             Exclude files from processing.
1554              
1555             This only applies to C<sync_bunch> operations. Operations like C<check_bunch> and
1556             C<exec_bunch> already ignore these and only operate on git repos.
1557              
1558             =item * B<exclude_non_git_dirs> => I<bool>
1559              
1560             Exclude non-git dirs from processing.
1561              
1562             This only applies to and C<sync_bunch> operations. Operations like C<check_bunch>
1563             and C<exec_bunch> already ignore these and only operate on git repos.
1564              
1565             =item * B<exclude_repos> => I<array[str]>
1566              
1567             Exclude some repos from processing.
1568              
1569             =item * B<exclude_repos_pat> => I<str>
1570              
1571             Specify regex pattern of repos to exclude.
1572              
1573             =item * B<include_repos> => I<array[str]>
1574              
1575             Specific git repos to sync, if not specified all repos in the bunch will be processed.
1576              
1577             =item * B<include_repos_pat> => I<str>
1578              
1579             Specify regex pattern of repos to include.
1580              
1581             =item * B<min_repo_access_time> => I<date>
1582              
1583             Limit to repos that are accessed (mtime, committed, status-ed, pushed) recently.
1584              
1585             This can significantly reduce the time to process the bunch if you are only
1586             interested in recent repos (which is most of the time unless you are doing a
1587             full check/sync).
1588              
1589             =item * B<repo> => I<str>
1590              
1591             Only process a single repo.
1592              
1593             =item * B<rsync_del> => I<bool>
1594              
1595             Whether to use --del rsync option.
1596              
1597             When rsync-ing non-repos, by default C<--del> option is not used for more safety
1598             because rsync is a one-way action. To add rsync C<--del> option, enable this
1599              
1600             =item * B<rsync_opt_maintain_ownership> => I<bool> (default: 0)
1601              
1602             Whether or not, when rsync-ing from source, we use -a (= -rlptgoD) or -rlptD (-a minus -go).
1603              
1604             Sometimes using -a results in failure to preserve permission modes on
1605             sshfs-mounted filesystem, while -rlptD succeeds, so by default we don't maintain
1606             ownership. If you need to maintain ownership (e.g. you run as root and the repos
1607             are not owned by root), turn this option on.
1608              
1609             =item * B<skip_mtime_check> => I<bool>
1610              
1611             Whether or not, when rsync-ing non-repos, we check mtime first.
1612              
1613             By default when we rsync a non-repo file/dir from source to target and both
1614             exist, to protect wrong direction of sync-ing we find the newest mtime in source
1615             or dir (if dir, then the dir is recursively traversed to find the file/subdir
1616             with the newest mtime). If target contains the newer mtime, the sync for that
1617             non-repo file/dir is aborted. If you want to force the rsync anyway, use this
1618             option.
1619              
1620             =item * B<source>* => I<str>
1621              
1622             Directory to check.
1623              
1624             =item * B<target>* => I<str>
1625              
1626             Destination bunch.
1627              
1628              
1629             =back
1630              
1631             Special arguments:
1632              
1633             =over 4
1634              
1635             =item * B<-dry_run> => I<bool>
1636              
1637             Pass -dry_run=E<gt>1 to enable simulation mode.
1638              
1639             =back
1640              
1641             Returns an enveloped result (an array).
1642              
1643             First element (status) is an integer containing HTTP status code
1644             (200 means OK, 4xx caller error, 5xx function error). Second element
1645             (msg) is a string containing error message, or 'OK' if status is
1646             200. Third element (payload) is optional, the actual result. Fourth
1647             element (meta) is called result metadata and is optional, a hash
1648             that contains extra information.
1649              
1650             Return value: (any)
1651              
1652             =head1 HOMEPAGE
1653              
1654             Please visit the project's homepage at L<https://metacpan.org/release/Git-Bunch>.
1655              
1656             =head1 SOURCE
1657              
1658             Source repository is at L<https://github.com/perlancar/perl-Git-Bunch>.
1659              
1660             =head1 BUGS
1661              
1662             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Git-Bunch>
1663              
1664             When submitting a bug or request, please include a test-file or a
1665             patch to an existing test-file that illustrates the bug or desired
1666             feature.
1667              
1668             =head1 SEE ALSO
1669              
1670              
1671             L<rsybak>.
1672              
1673             L<http://joeyh.name/code/mr/>. You probably want to use this instead. I<mr> supports other control version
1674             software aside from git, doesn't restrict you to put all your repos in one
1675             directory, supports more operations, and has been developed since 2007. Had I
1676             known about I<mr>, I probably wouldn't have started gitbunch. On the other hand,
1677             gitbunch is simpler (I think), doesn't require any config file, and can
1678             copy/sync files/directories not under source control. I mainly use gitbunch to
1679             quickly: 1) check whether there are any of my repositories which have
1680             uncommitted changes; 2) synchronize (pull/push) to other locations. I put all my
1681             data in one big gitbunch directory; I find it simpler. gitbunch works for me and
1682             I use it daily.
1683              
1684             L<File::RsyBak>
1685              
1686             =head1 AUTHOR
1687              
1688             perlancar <perlancar@cpan.org>
1689              
1690             =head1 COPYRIGHT AND LICENSE
1691              
1692             This software is copyright (c) 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013, 2012, 2011 by perlancar@cpan.org.
1693              
1694             This is free software; you can redistribute it and/or modify it under
1695             the same terms as the Perl 5 programming language system itself.
1696              
1697             =cut