File Coverage

blib/lib/App/Multigit.pm
Criterion Covered Total %
statement 47 139 33.8
branch 0 32 0.0
condition 0 24 0.0
subroutine 16 38 42.1
pod 10 10 100.0
total 73 243 30.0


line stmt bran cond sub pod time code
1             package App::Multigit;
2              
3 1     1   13557 use 5.014;
  1         2  
4 1     1   3 use strict;
  1         1  
  1         18  
5 1     1   2 use warnings FATAL => 'all';
  1         4  
  1         41  
6              
7 1     1   436 use List::UtilsBy qw(sort_by);
  1         1175  
  1         61  
8 1     1   451 use Capture::Tiny qw(capture);
  1         24049  
  1         81  
9 1     1   752 use File::Find::Rule;
  1         9669  
  1         7  
10 1     1   616 use Future::Utils qw(fmap);
  1         7526  
  1         55  
11 1     1   455 use Path::Class;
  1         13621  
  1         53  
12 1     1   429 use Config::INI::Reader;
  1         25797  
  1         33  
13 1     1   443 use Config::INI::Writer;
  1         2544  
  1         24  
14 1     1   818 use IPC::Run;
  1         23831  
  1         41  
15 1     1   543 use Try::Tiny;
  1         1604  
  1         72  
16              
17 1     1   580 use App::Multigit::Future;
  1         3  
  1         13  
18 1     1   579 use App::Multigit::Repo;
  1         2  
  1         7  
19 1     1   30 use App::Multigit::Loop qw(loop);
  1         18  
  1         42  
20              
21 1     1   3 use Exporter 'import';
  1         1  
  1         1151  
22              
23             our @EXPORT_OK = qw/
24             mgconfig mg_parent
25             all_repositories selected_repositories
26             base_branch set_base_branch mg_each
27             write_config
28             /;
29              
30             =head1 NAME
31              
32             App::Multigit - Run commands on a bunch of git repositories without having to
33             deal with git subrepositories.
34              
35             =cut
36              
37             our $VERSION = '0.18';
38              
39             =head1 PACKAGE VARS
40              
41             =head2 %BEHAVIOUR
42              
43             This holds configuration set by options passed to the C script itself.
44              
45             Observe that C will pass C to
46             C, and C to C. It is those C that will
47             affect C<%BEHAVIOUR>.
48              
49             Scripts may also therefore change C<%BEHAVIOUR> themselves, but it is probably
50             badly behaved to do so.
51              
52             =head3 report_on_no_output
53              
54             Defaults to true; this should be used by scripts to determine whether to bother
55             mentioning repositories that gave no output at all for the given task. If you
56             use C, this will be honoured by default.
57              
58             Controlled by the C environment variable.
59              
60             =head3 ignore_stdout
61              
62             =head3 ignore_stderr
63              
64             These default to false, and will black-hole these streams wherever we have
65             control to do so.
66              
67             Controlled by the C environment variables.
68              
69             =head3 concurrent_processes
70              
71             Number of processes to run in parallel. Defaults to 20.
72              
73             Controlled by the C environment variable.
74              
75             =head3 skip_readonly
76              
77             Do nothing to repositories that have C set in C<.mgconfig>.
78              
79             Controlled by the C environment variable.
80              
81             =cut
82              
83             our %BEHAVIOUR = (
84             report_on_no_output => $ENV{MG_REPORT_ON_NO_OUTPUT} // 1,
85             ignore_stdout => !!$ENV{MG_IGNORE_STDOUT},
86             ignore_stderr => !!$ENV{MG_IGNORE_STDERR},
87             concurrent => $ENV{MG_CONCURRENT_PROCESSES} // 20,
88             skip_readonly => !!$ENV{MG_SKIP_READONLY},
89             output_only => !!$ENV{MG_OUTPUT_ONLY},
90             );
91              
92             =head2 @SELECTED_REPOS
93              
94             If this is not empty, it should contain paths to repositories. Relative paths
95             will be determined relative to L>|/mg_root>.
96              
97             Instead of using the C<.mgconfig>, the directories in here will be used as the
98             list of repositories on which to work.
99              
100             Each repository's C remote will be interrogated. If this exists in the
101             C<.mgconfig> then it will be used as normal; otherwise, it will be treated as
102             though it had the default configuration.
103              
104             =cut
105              
106             our @SELECTED_REPOS;
107              
108             =head1 FUNCTIONS
109              
110             These are not currently exported.
111              
112             =head2 mgconfig
113              
114             Returns C<.mgconfig>. This is a stub to be later configurable, but also
115             to stop me typoing it all the time.
116              
117             =cut
118              
119             sub mgconfig() {
120 0     0 1   return '.mgconfig';
121             }
122              
123             =head2 mg_parent
124              
125             Tries to find the closest directory with an C in it. Dies if there is
126             no mgconfig here. Optionally accepts the directory to start with.
127              
128             =cut
129              
130             sub mg_parent {
131 0     0 1   my $pwd;
132 0 0         if (@_) {
133 0           $pwd = dir(shift);
134             }
135             else {
136 0           $pwd = dir;
137             }
138 0           $pwd = $pwd->absolute;
139              
140             PARENT: {
141 0           do {
  0            
142 0 0         return $pwd if -e $pwd->file(mgconfig);
143 0 0         last PARENT if $pwd eq $pwd->parent;
144             }
145             while ($pwd = $pwd->parent);
146             }
147              
148 0           die "Could not find .mgconfig in any parent directory";
149             }
150              
151             =head2 all_repositories
152              
153             Returns a hashref of all repositories under C.
154              
155             The keys are the repository directories relative to C, and the values
156             are the hashrefs from the config, if any.
157              
158             =cut
159              
160             sub all_repositories {
161 0   0 0 1   my $pwd = shift // dir->absolute;
162 0           my $mg_parent = mg_parent $pwd;
163              
164 0           my $cfg = Config::INI::Reader->read_file($mg_parent->file(mgconfig));
165              
166 0           for (keys %$cfg) {
167 0   0       $cfg->{$_}->{dir} //= dir($_)->basename =~ s/\.git$//r;
168 0   0       $cfg->{$_}->{url} //= $_;
169             }
170              
171 0           return $cfg;
172             }
173              
174             =head2 selected_repositories
175              
176             This returns the repository configuration as determined by
177             L>|/@SELECTED_REPOS>. Directories that exist in the main
178             config (L) will have their configuration honoured, but unknown
179             directories will have default configuration.
180              
181             =cut
182              
183             sub selected_repositories {
184 0     0 1   my $all_repositories = all_repositories;
185              
186 0 0         return $all_repositories unless @SELECTED_REPOS;
187              
188 0           my $bydir = +{ map {$_->{dir} => $_} values %$all_repositories };
  0            
189              
190 0           my $selected_repos = {};
191              
192 0           my $parent = mg_parent;
193              
194 0           for my $dir (@SELECTED_REPOS) {
195             # Allow people to not have to worry about extracting blanks
196 0 0         next if not $dir;
197              
198 0           $dir = dir($dir)->relative($parent);
199 0 0         if (exists $bydir->{$dir}) {
200 0           $selected_repos->{ $bydir->{$dir}->{url} } = $bydir->{$dir};
201             }
202             else {
203             my $url =
204             try {
205 0     0     _sensible_remote_url($dir);
206             }
207             catch {
208 0     0     warn $_;
209             }
210 0 0         or next;
211              
212 0           $selected_repos->{ $url } = {
213             url => $url,
214             dir => $dir,
215             }
216             }
217             }
218              
219 0           return $selected_repos;
220             }
221              
222             =head2 each($command[, $ia_config])
223              
224             For each configured repository, C<$command> will be run. Each command is run in
225             a separate process which Cs into the repository first. Optionally, the
226             C<$ia_config> hashref may be provided; this will be passed to
227             L.
228              
229             It returns a convergent L that represents all tasks. When
230             this Future completes, all tasks are complete.
231              
232             =head4 Subref form
233              
234             The most useful form is the subref form. The subref must return a Future; when
235             this Future completes, that repository's operations are done.
236              
237             The convergent Future (C<$future> below) completes when all component Futures
238             (the return value of C, below) have completed. Thus the script blocks at
239             the C<< $future->get >> until all repositories have reported completion.
240              
241             use curry;
242             my $future = App::Multigit::each(sub {
243             my $repo = shift;
244             $repo
245             ->run(\&do_a_thing)
246             ->then($repo->curry::run(\&do_another_thing))
247             ;
248             });
249              
250             my @results = $future->get;
251              
252             See C for a simple implementation of this.
253              
254             The Future can complete with whatever you like, but be aware that C returns
255             a hash-shaped list; see the docs for
256             L. This means it is often
257             useful for the very last thing in your subref to be a transformation - something
258             that extracts data from the C<%data> hash and turns it into a usefully-shaped
259             list.
260              
261             The example C does this, whereas C uses
262             C.
263              
264             L in App::Multigit::Repo implements
265             a sensible directory-plus-output transformation for common usage.
266              
267             use curry;
268             my $future = App::Multigit::each(sub {
269             my $repo = shift;
270             $repo
271             ->run(\&do_a_thing)
272             ->then($repo->curry::run(\&do_another_thing))
273             ->then($repo->curry::report)
274             ;
275             });
276              
277             The subref given to C is passed the C<%data> hash from the previous
278             command. C<%data> is pre-prepared with blank values, so you don't have to check
279             for definedness to avoid warnings, keeping your subrefs nice and clean.
280              
281             sub do_a_thing {
282             my ($repo_obj, %data) = @_;
283             ...
284             }
285              
286             Thus you can chain them in any order.
287              
288             use curry;
289             my $future = App::Multigit::each(sub {
290             my $repo = shift;
291             $repo
292             ->run(\&do_another_thing)
293             ->then($repo->curry::run(\&do_a_thing))
294             ->then($repo->curry::report)
295             ;
296             });
297              
298             Observe also that the interface to C allows for the arrayref form as well:
299              
300             use curry;
301             my $future = App::Multigit::each(sub {
302             my $repo = shift;
303             $repo
304             ->run([qw/git checkout master/])
305             ->then($repo->curry::run(\&do_another_thing))
306             ;
307             });
308              
309             A command may fail. In this case, the Future will fail, and if not handled, the
310             script will die - which is the default behaviour of Future. You can use
311             L to catch this and continue.
312              
313             use curry;
314             my $future = App::Multigit::each(sub {
315             my $repo = shift;
316             $repo
317             ->run([qw{git rebase origin/master}])
318             ->else([qw{git rebase --abort])
319             ->then($repo->curry::report)
320             ;
321             });
322              
323             The failure is thrown in a manner that conforms to the expected Future fail
324             interface, i.e. there is an error message and an error code in there. Following
325             these is the C<%data> hash that is consistent to all invocations of C. That
326             means that when you do C, you should be aware that there will be two extra
327             parameters at the start of the argument list.
328              
329             use curry;
330             my $future = App::Multigit::each(sub {
331             my $repo = shift;
332             $repo
333             ->run([qw{git rebase origin/master}])
334             ->else(sub {
335             my ($message, $error, %data) = @_;
336             ...
337             })
338             ->then($repo->curry::report)
339             ;
340             });
341              
342             In the case that you don't care whether the command succeeds or fails, you can
343             use L to catch the failure and pretend it
344             wasn't actually a failure.
345              
346             use curry;
347             my $future = App::Multigit::each(sub {
348             my $repo = shift;
349             $repo
350             ->run([qw{git rebase origin/master}])
351             ->finally($repo->curry::report)
352             ;
353             });
354              
355             Despite the name, C does not have to be the final thing. Think
356             "finally" as in "try/catch/finally". In the following code, C simply
357             returns the C<%data> hash, because C transforms a failure into a
358             success and discards the error information.
359              
360             use curry;
361             my $future = App::Multigit::each(sub {
362             my $repo = shift;
363             $repo
364             ->run([qw{git rebase origin/master}])
365             ->finally(sub { @_ })
366             ->then(\&carry_on_camping)
367             ->then($repo->curry::report)
368             ;
369             });
370              
371             =head4 Arrayref form
372              
373             In the arrayref form, the C<$command> is passed directly to C in
374             L. The
375             Futures returned thus are collated and the list of return values is thus
376             collated.
377              
378             Because L completes a Future
379             with a hash-shaped list, the convergent Future that C returns will be a
380             useless list of all flattened hashes. For this reason it is not actually very
381             much use to do this - but it is not completely useless, because all hashes are
382             the same size:
383              
384             my $future = App::Multigit::each([qw/git reset --hard HEAD/]);
385             my @result = $future->get;
386              
387             my $natatime = List::MoreUtils::natatime(10, @result);
388              
389             while (my %data = $natatime->()) {
390             say $data{stdout};
391             }
392              
393             However, the C<%data> hashes do not contain repository information; just the
394             output. It is expected that if repository information is required, the closure
395             form is used.
396              
397             =cut
398              
399             sub each {
400 0     0 1   my $command = shift;
401 0           my $ia_config = shift;
402 0           my $repos = selected_repositories;
403              
404 0     0     my $f = fmap { _run_in_repo($command, $_[0], $repos->{$_[0]}, $ia_config) }
405             foreach => [ keys %$repos ],
406             concurrent => $BEHAVIOUR{concurrent_processes},
407 0           ;
408              
409 0           bless $f, 'App::Multigit::Future';
410             }
411              
412             =head2 mg_each
413              
414             This is the exported name of C
415              
416             use App::Multigit qw/mg_each/;
417              
418             =cut
419              
420             *mg_each = \&each;
421              
422             sub _run_in_repo {
423 0     0     my ($cmd, $repo, $config, $ia_config) = @_;
424              
425             return App::Multigit::Future->done
426 0 0 0       if $BEHAVIOUR{skip_readonly} and $config->{readonly};
427              
428 0 0         if (ref $cmd eq 'ARRAY') {
429 0           App::Multigit::Repo->new(
430             name => $repo,
431             config => $config
432             )->run($cmd, ia_config => $ia_config);
433             }
434             else {
435 0           App::Multigit::Repo->new(
436             name => $repo,
437             config => $config
438             )->$cmd;
439             }
440             }
441              
442             =head2 mkconfig($workdir)
443              
444             Scans C<$workdir> for git directories and registers each in C<.mgconfig>. If the
445             config file already exists it will be appended to; existing config will be
446             preserved where possible.
447              
448             =cut
449              
450             sub mkconfig {
451 0   0 0 1   my $workdir = shift // mg_parent;
452 0           my @dirs = File::Find::Rule
453             ->relative
454             ->directory
455             ->not_name('.git')
456             ->maxdepth(1)
457             ->mindepth(1)
458             ->in($workdir);
459              
460 0           my %config;
461              
462             # If it's already inited, we'll keep the config
463             %config = try {
464 0     0     %{ all_repositories($workdir) }
  0            
465 0     0     } catch {};
466              
467 0           for my $dir (@dirs) {
468             my $url = try {
469 0     0     _sensible_remote_url($dir);
470             }
471             catch {
472 0     0     warn $_;
473 0           0;
474             }
475 0 0         or next;
476 0           $config{$url}->{dir} = $dir;
477             }
478              
479 0           write_config(\%config, $workdir);
480             }
481              
482             =head2 write_config
483              
484             Write a .mgconfig configuration file.
485              
486             =cut
487              
488             sub write_config
489             {
490 0     0 1   my $config = shift;
491 0   0       my $workdir = shift // mg_parent;
492 0           my $config_filename = dir($workdir)->file(mgconfig);
493 0           Config::INI::Writer->write_file($config, $config_filename);
494             }
495              
496             =head2 clean_config
497              
498             Checks the C<.mgconfig> for directories that don't exist and removes the associated repo section.
499              
500             =cut
501              
502             sub clean_config {
503 0     0 1   my $config = all_repositories;
504 0   0       my $workdir = shift // mg_parent;
505              
506 0           for my $url (keys %$config) {
507 0           my $conf = $config->{$url};
508 0           my $dir = dir($conf->{dir});
509              
510 0 0         if ($dir->is_relative) {
511 0           $dir = $dir->absolute($workdir);
512             }
513              
514 0 0         unless (-e $dir) {
515 0           delete $config->{$url};
516             }
517             }
518              
519 0           my $config_filename = $workdir->file(mgconfig);
520 0           Config::INI::Writer->write_file($config, $config_filename);
521             }
522              
523             # Fetch either origin URL, or any URL. Dies if none.
524             sub _sensible_remote_url {
525 0     0     my $dir = shift;
526             my ($remotes, $stderr, $exitcode) = capture {
527 0 0   0     system qw(git -C), $dir, qw(remote -v)
528             and return;
529 0           };
530              
531 0 0         die $stderr if $exitcode;
532              
533 0 0         if (not $remotes) {
534 0           die "No remotes configured for $dir\n";
535             }
536              
537 0           my @remotes = split /\n/, $remotes;
538 0           my %remotes = map {split ' '} @remotes;
  0            
539              
540 0   0       return $remotes{origin} // $remotes{ (keys %remotes)[0] }
541             }
542              
543             =head2 base_branch
544              
545             Returns the branch that the base repository is on -the repository that contains
546             the C<.mgconfig> or equivalent.
547              
548             The purpose of this is to switch the entire project onto a feature branch;
549             scripts can use this as the cue to work against a branch other than master.
550              
551             This will die if the base repository is not on a branch, because if you've asked
552             for it, giving you a default will more likely be a hindrance than a help.
553              
554             =cut
555              
556             sub base_branch() {
557 0     0 1   my $dir = mg_parent;
558              
559             my ($stdout) = capture {
560 0     0     system qw(git -C), $dir, qw(branch)
561 0           };
562              
563 0           my ($branch) = $stdout =~ /\* (.+)/;
564 0 0         return $branch if $branch;
565              
566 0           die "The base repository is not on a branch!";
567             }
568              
569             =head2 set_base_branch($branch)
570              
571             Checks out the provided branch name on the parent repository. Beware of using a
572             branch name that already exists, because this will switch to that branch if it
573             does.
574              
575             =cut
576              
577             sub set_base_branch {
578 0     0 1   my $base_branch = shift;
579              
580             my ($stdout, $stderr) = capture {
581 0     0     system qw(git -C), mg_parent, qw(checkout -B), $base_branch
582 0           };
583             }
584              
585             1;
586              
587             __END__