File Coverage

blib/lib/App/Multigit.pm
Criterion Covered Total %
statement 47 136 34.5
branch 0 32 0.0
condition 0 21 0.0
subroutine 16 37 43.2
pod 9 9 100.0
total 72 235 30.6


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