File Coverage

blib/lib/Git/Bunch.pm
Criterion Covered Total %
statement 295 462 63.8
branch 128 254 50.3
condition 39 87 44.8
subroutine 28 34 82.3
pod 4 4 100.0
total 494 841 58.7


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