File Coverage

blib/lib/Doit/Git.pm
Criterion Covered Total %
statement 267 306 87.2
branch 149 200 74.5
condition 27 36 75.0
subroutine 28 28 100.0
pod 10 12 83.3
total 481 582 82.6


line stmt bran cond sub pod time code
1             # -*- perl -*-
2              
3             #
4             # Author: Slaven Rezic
5             #
6             # Copyright (C) 2017,2018,2019,2022 Slaven Rezic. All rights reserved.
7             # This package is free software; you can redistribute it and/or
8             # modify it under the same terms as Perl itself.
9             #
10             # Mail: slaven@rezic.de
11             # WWW: http://www.rezic.de/eserte/
12             #
13              
14             package Doit::Git; # Convention: all commands here should be prefixed with 'git_'
15              
16 2     2   14 use strict;
  2         4  
  2         61  
17 2     2   12 use warnings;
  2         3  
  2         89  
18             our $VERSION = '0.028';
19              
20 2     2   10 use Doit::Log;
  2         4  
  2         121  
21 2     2   15 use Doit::Util qw(in_directory);
  2         3  
  2         6284  
22              
23             sub _pipe_open (@);
24              
25 2     2 0 23 sub new { bless {}, shift }
26 2     2 0 12 sub functions { qw(git_repo_update git_short_status git_root git_get_commit_hash git_get_commit_files git_get_changed_files git_is_shallow git_current_branch git_config git_get_default_branch) }
27              
28             sub git_repo_update {
29 42     42 1 1018 my($self, %opts) = @_;
30 42         240 my $repository = delete $opts{repository};
31 42 100       140 my @repository_aliases = @{ delete $opts{repository_aliases} || [] };
  42         913  
32 42         253 my $directory = delete $opts{directory};
33 42   100     643 my $origin = delete $opts{origin} || 'origin';
34 42         206 my $branch = delete $opts{branch};
35 42         115 my $allow_remote_url_change = delete $opts{allow_remote_url_change};
36 42         140 my $clone_opts = delete $opts{clone_opts};
37 42   100     481 my $refresh = delete $opts{refresh} || 'always';
38 42 100       1009 if ($refresh !~ m{^(always|never)$}) { error "refresh may be 'always' or 'never'" }
  1         15  
39 41         166 my $quiet = delete $opts{quiet};
40 41 100       261 error "Unhandled options: " . join(" ", %opts) if %opts;
41              
42 40         290 my $has_changes = 0;
43 40         320 my $do_clone;
44 40 100       1459 if (!-e $directory) {
45 13         112 $do_clone = 1;
46             } else {
47 27 100       543 if (!-d $directory) {
48 1         28 error "'$directory' exists, but is not a directory\n";
49             }
50 26 100       700 if (!-d "$directory/.git") {
51 2 100       37 if (_is_dir_empty($directory)) {
52 1         12 $do_clone = 1;
53             } else {
54 1         34 error "No .git directory found in non-empty '$directory', refusing to clone...\n";
55             }
56             }
57             }
58 38 100       231 if (!$do_clone) {
59             in_directory {
60 24     24   87 my $actual_repository = eval { $self->info_qx({quiet=>1}, qw(git config --get), "remote.$origin.url") };
  24         580  
61 24 100       396 if (!defined $actual_repository) {
62             # Remote does not exist yet --- create it.
63 1         45 $self->system(qw(git remote add), $origin, $repository);
64             } else {
65 23         115 chomp $actual_repository;
66 23 100 100     359 if ($actual_repository ne $repository && !grep { $_ eq $actual_repository } @repository_aliases) {
  2         42  
67 3         83 my @change_cmd = ('git', 'remote', 'set-url', $origin, $repository);
68 3 100       28 if ($allow_remote_url_change) {
69 1         35 info "Need to change remote URL for $origin";
70 1         190 $self->system(@change_cmd);
71             } else {
72 2 100       80 error
73             "In $directory: remote $origin does not point to $repository" . (@repository_aliases ? " (or any of the following aliases: @repository_aliases)" : "") . ", but to $actual_repository\n" .
74             "Please run manually\n" .
75             " @change_cmd\n" .
76             "or specify allow_remote_url_change=>1\n";
77             }
78             }
79             }
80              
81 22         191 my $switch_later;
82 22 100       163 if (defined $branch) { # maybe branch switching necessary?
83 11 100       158 if ($branch =~ m{^refs/remotes/(.*)}) { # extract branch with remote
84 1         62 $branch = $1;
85             }
86 11         297 my $current_branch = $self->git_current_branch;
87 11 100 66     224 if (!defined $current_branch || $current_branch ne $branch) {
88 7 100       84 if (eval { $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout), $branch); 1 }) {
  7         406  
  4         409  
89 4         57 $has_changes = 1;
90             } else {
91             # Cannot switch now to the branch. Maybe a
92             # git-fetch has to be done first, as the
93             # branch is not yet in the clone --- try
94             # later.
95 3         53 $switch_later = 1;
96             }
97             }
98 11         220 my %info;
99 11         280 $self->git_current_branch(info_ref => \%info);
100 11 100       800 if ($info{detached}) {
101 5         45 $switch_later = 1; # because a "git pull" wouldn't update a detached branch
102             }
103             }
104              
105 22 100       247 if ($refresh eq 'always') {
106 21         896 $self->system({show_cwd=>1,quiet=>$quiet}, qw(git fetch), $origin);
107 21         2694 my $status = $self->git_short_status(untracked_files => 'no');
108 21 100       597 if ($status =~ m{>$}) {
109             # may actually fail if diverged (status=<>)
110             # or untracked/changed files would get overwritten
111 5         314 $self->system({show_cwd=>1,quiet=>$quiet}, qw(git pull), $origin); # XXX actually would be more efficient to do a merge or rebase, but need to figure out how git does it exactly...
112 4         317 $has_changes = 1;
113             } # else: ahead, diverged, or something else
114             }
115              
116 21 100       847 if ($switch_later) {
117 8         74 my($commit_before, $branch_before);
118 8 100       72 if (!$has_changes) {
119 5         260 $commit_before = $self->git_get_commit_hash;
120 5         190 $branch_before = $self->git_current_branch;
121             }
122 8 100       84 if (!eval { $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout), $branch) }) {
  8         620  
123             # Possible reason for the failure: $branch exists
124             # as a remote branch in multiple remotes. Try
125             # again by explicitly specifying the remote.
126             # --track exists since approx git 1.5.1
127 1         53 $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout -b), $branch, qw(--track), "$origin/$branch");
128             }
129 8 100 100     695 if ($commit_before
      100        
130             && ( $self->git_get_commit_hash ne $commit_before
131             || $self->git_current_branch ne $branch_before
132             )
133             ) {
134 4         208 $has_changes = 1;
135             }
136             }
137 24         1007 } $directory;
138             } else {
139 14         175 my @cmd = (qw(git clone --origin), $origin);
140 14 100       97 if (defined $branch) {
141 2 100       32 if ($branch =~ m{^refs/remotes/[^/]+/(.*)}) { # extract branch without remote
142 1         14 $branch = $1;
143             }
144 2         21 push @cmd, "--branch", $branch;
145             }
146 14 100       115 if ($clone_opts) {
147 1         17 push @cmd, @$clone_opts;
148             }
149 14         87 push @cmd, $repository, $directory;
150 14         158 $self->system(@cmd);
151 14         1403 $has_changes = 1;
152             }
153 35         4608 $has_changes;
154             }
155              
156             sub git_short_status {
157 59     59 1 851 my($self, %opts) = @_;
158 59         447 my $directory = delete $opts{directory};
159 59         205 my $untracked_files = delete $opts{untracked_files};
160 59 100       1449 if (!defined $untracked_files) {
    100          
161 21         133 $untracked_files = 'normal';
162             } elsif ($untracked_files !~ m{^(normal|no)$}) {
163 1         6 error "only values 'normal' or 'no' supported for untracked_files";
164             }
165 58 100       626 error "Unhandled options: " . join(" ", %opts) if %opts;
166              
167             in_directory {
168 57     57   1648 local $ENV{LC_ALL} = 'C';
169              
170 57         224 my $untracked_marker = '';
171             {
172 57         614 my @cmd = ("git", "status", "--untracked-files=$untracked_files", "--porcelain");
173 57 50       580 my $fh = _pipe_open(@cmd)
174             or error "Can't run '@cmd': $!";
175 57         454 my $has_untracked;
176             my $has_uncommitted;
177 57         217159 while (<$fh>) {
178 13 100       363 if (m{^\?\?}) {
179 6         47 $has_untracked++;
180             } else {
181 7         77 $has_uncommitted++;
182             }
183             # Shortcut, exit as early as possible
184 13 100       832 if ($has_uncommitted) {
185 8 100       136 if ($has_untracked) {
    100          
186 1         265 return '<<*';
187             } elsif ($untracked_files eq 'no') {
188 3         746 return '<<';
189             } # else we have to check further, for possible untracked files
190             }
191             }
192 53 100       688 if ($has_uncommitted) {
    100          
193 3         638 return '<<';
194             } elsif ($has_untracked) {
195 5         28 $untracked_marker = '*'; # will be combined later
196 5         348 last;
197             }
198 45 100       3843 close $fh
199             or error "Error while running '@cmd': $!";
200             }
201              
202             {
203 57         148 my @cmd = ("git", "status", "--untracked-files=no");
  48         562  
  48         1013  
204 48 50       749 my $fh = _pipe_open(@cmd)
205             or error "Can't run '@cmd': $!";
206 48         272 my $l;
207 48         219394 $l = <$fh>;
208 48         469 $l = <$fh>;
209 48 100       8748 if ($l =~ m{^(# )?Your branch is ahead}) {
    100          
    100          
210 4         1300 return '<'.$untracked_marker;
211             } elsif ($l =~ m{^(# )?Your branch is behind}) {
212 9         2914 return $untracked_marker.'>';
213             } elsif ($l =~ m{^(# )?Your branch and .* have diverged}) {
214 4         1560 return '<'.$untracked_marker.'>';
215             }
216             }
217              
218 31 50       1550 if (-f ".git/svn/.metadata") {
219             # simple-minded heuristics, works only with svn standard branch
220             # layout
221 0         0 my $root_dir = $self->git_root;
222 0 0       0 if (open my $fh_remote, "$root_dir/.git/refs/remotes/trunk") {
223 0 0       0 if (open my $fh_local, "$root_dir/.git/refs/heads/master") {
224 0         0 chomp(my $sha1_remote = <$fh_remote>);
225 0         0 chomp(my $sha1_local = <$fh_local>);
226 0 0       0 if ($sha1_remote ne $sha1_local) {
227 0         0 my $remote_is_newer;
228 0 0       0 if (my $log_fh = _pipe_open('git', 'log', '--pretty=format:%H', 'master..remotes/trunk')) {
229 0 0       0 if (scalar <$log_fh>) {
230 0         0 $remote_is_newer = 1;
231             }
232             }
233 0         0 my $local_is_newer;
234 0 0       0 if (my $log_fh = _pipe_open('git', 'log', '--pretty=format:%H', 'remotes/trunk..master')) {
235 0 0       0 if (scalar <$log_fh>) {
236 0         0 $local_is_newer = 1;
237             }
238             }
239 0 0 0     0 if ($remote_is_newer && $local_is_newer) {
    0          
    0          
240 0         0 return '<'.$untracked_marker.'>';
241             } elsif ($remote_is_newer) {
242 0         0 return $untracked_marker.'>';
243             } elsif ($local_is_newer) {
244 0         0 return '<'.$untracked_marker;
245             } else {
246 0         0 return '?'; # Should never happen
247             }
248             }
249             }
250             }
251             }
252              
253 31         4315 return $untracked_marker;
254              
255 57         2460 } $directory;
256             }
257              
258             sub git_root {
259 62     62 1 634 my($self, %opts) = @_;
260 62         328 my $directory = delete $opts{directory};
261 62 100       233 error "Unhandled options: " . join(" ", %opts) if %opts;
262              
263             in_directory {
264 61     61   1091 chomp(my $dir = $self->info_qx({quiet=>1}, 'git', 'rev-parse', '--show-toplevel'));
265 54         3226 $dir;
266 61         1244 } $directory;
267             }
268              
269             sub git_get_commit_hash {
270 20     20 1 274 my($self, %opts) = @_;
271 20         135 my $directory = delete $opts{directory};
272 20         72 my $commit = delete $opts{commit};
273 20 100       151 error "Unhandled options: " . join(" ", %opts) if %opts;
274              
275             in_directory {
276 19 100   19   544 chomp(my $commit = $self->info_qx({quiet=>1}, 'git', 'log', '-1', '--format=%H', (defined $commit ? $commit : ())));
277 17         1314 $commit;
278 19         634 } $directory;
279             }
280              
281             sub git_get_commit_files {
282 8     8 1 69 my($self, %opts) = @_;
283 8         32 my $directory = delete $opts{directory};
284 8 100       43 my $commit = delete $opts{commit}; if (!defined $commit) { $commit = 'HEAD' }
  8         35  
  6         21  
285 8 100       41 error "Unhandled options: " . join(" ", %opts) if %opts;
286              
287 7         24 my @files;
288             in_directory {
289 6     6   63 my @cmd = ('git', 'show', $commit, '--pretty=format:', '--name-only');
290 6 50       58 my $fh = _pipe_open(@cmd)
291             or error "Error running @cmd: $!";
292 6         12911 my $first = <$fh>;
293 6 100 66     224 if (defined $first && $first ne "\n") { # first line is empty for older git versions (e.g. 1.7.x)
294 3         45 chomp $first;
295 3         42 push @files, $first;
296             }
297 6         324 while(<$fh>) {
298 1         23 chomp;
299 1         44 push @files, $_;
300             }
301 6 100       521 close $fh
302             or error "Error while running @cmd: $!";
303 7         178 } $directory;
304 3         308 @files;
305             }
306              
307             sub git_get_changed_files {
308 11     11 1 54 my($self, %opts) = @_;
309 11         54 my $directory = delete $opts{directory};
310 11         37 my $ignore_untracked = delete $opts{ignore_untracked};
311 11 100       55 error "Unhandled options: " . join(" ", %opts) if %opts;
312              
313 10         107 my @files;
314             in_directory {
315 10     10   57 my @cmd = qw(git status --porcelain);
316 10 50       68 my $fh = _pipe_open(@cmd)
317             or error "Error running @cmd: $!";
318 10         28231 while(<$fh>) {
319 7         74 chomp;
320 7 100 66     131 next if $ignore_untracked && m{^\?\?};
321 6         114 s{^...}{};
322 6         272 push @files, $_;
323             }
324 10 100       957 close $fh
325             or error "Error while running @cmd: $!";
326 10         306 } $directory;
327 8         830 @files;
328             }
329              
330             sub git_is_shallow {
331 5     5 1 46 my($self, %opts) = @_;
332 5         33 my $directory = delete $opts{directory};
333 5 100       30 error "Unhandled options: " . join(" ", %opts) if %opts;
334              
335 4         72 my $git_root = $self->git_root(directory => $directory);
336 2 100       155 -f "$git_root/.git/shallow" ? 1 : 0;
337             }
338              
339             sub git_current_branch {
340 50     50 1 614 my($self, %opts) = @_;
341 50         241 my $directory = delete $opts{directory};
342 50         178 my $info_ref = delete $opts{info_ref};
343 50 100       345 error "Unhandled options: " . join(" ", %opts) if %opts;
344              
345             in_directory {
346 49     49   737 my $git_root = $self->git_root;
347 47         680 my $fh;
348             my $this_head;
349 47 50       4188 if (open $fh, "<", "$git_root/.git/HEAD") {
350 47         1457 chomp($this_head = <$fh>);
351 47 100       1388 if ($this_head =~ m{refs/heads/(\S+)}) {
352 29         2497 return $1;
353             }
354             }
355              
356             # fallback to git-status
357 18         356 $ENV{LC_ALL} = 'C';
358 18 50       279 if ($fh = _pipe_open(qw(git status))) {
359 18         82082 chomp($_ = <$fh>);
360 18 50       453 if (/^On branch (.*)/) {
361 0 0       0 if ($info_ref) {
362 0         0 $info_ref->{fallback} = 'git-status';
363             }
364 0         0 return $1;
365             }
366 18 50       612 if (/^.* detached at (.*)/) {
367 18 100       81 if ($info_ref) {
368 10         138 $info_ref->{detached} = 1;
369 10         138 $info_ref->{fallback} = 'git-status';
370             }
371 18         3270 return $1;
372             }
373 0 0       0 if (/^\Q# Not currently on any branch./) {
374             # Probably old git (~ 1.5 ... 1.7)
375 0 0       0 if (my $fh2 = _pipe_open(qw(git show-ref))) {
376 0         0 while(<$fh2>) {
377 0         0 chomp;
378 0 0       0 if (my($sha1, $ref) = $_ =~ m{^(\S+)\s+refs/remotes/(.*)$}) {
379 0 0       0 if ($sha1 eq $this_head) {
380 0 0       0 if ($info_ref) {
381 0         0 $info_ref->{detached} = 1;
382 0         0 $info_ref->{fallback} = 'git-show-ref';
383             }
384 0         0 return $ref;
385             }
386             }
387             }
388 0 0       0 close $fh2
389             or warning "Problem while running 'git show-ref': $!";
390             } else {
391 0         0 warning "Error running 'git show-ref': $!";
392             }
393             }
394             }
395              
396 0         0 undef;
397 49         1932 } $directory;
398             }
399              
400             sub git_config {
401 23     23 1 423 my($self, %opts) = @_;
402 23         109 my $directory = delete $opts{directory};
403 23         104 my $key = delete $opts{key};
404 23         56 my $val = delete $opts{val};
405 23         48 my $unset = delete $opts{unset};
406 23 100       543 error "Unhandled options: " . join(" ", %opts) if %opts;
407 22 100 100     198 if (defined $val && $unset) {
408 1         22 error "Don't specify both 'unset' and 'val'";
409             }
410              
411             in_directory {
412 2     2   19 no warnings 'uninitialized'; # $old_val may be undef
  2         4  
  2         1781  
413 21     21   59 chomp(my($old_val) = eval { $self->info_qx({quiet=>1}, qw(git config), $key) });
  21         393  
414 21 100       633 if ($unset) {
415 2 100       194 if ($@) {
416 1 50       20 if ($@->{exitcode} == 1) {
417             # already non-existent (or even invalid)
418 1         118 0;
419             } else {
420 0         0 error "git config $key failed with exitcode $@->{exitcode}";
421             }
422             } else {
423 1 50       52 $self->system(qw(git config --unset), $key, (defined $val ? $val : ()));
424 1         178 1;
425             }
426             } else {
427 19 100       92 if (!defined $val) {
428 12         1175 $old_val;
429             } else {
430 7 100 100     129 if (!defined $old_val || $old_val ne $val) {
431 6         166 $self->system(qw(git config), $key, $val);
432 4         1110 1;
433             } else {
434 1         127 0;
435             }
436             }
437             }
438 21         484 } $directory;
439             }
440              
441             sub git_get_default_branch {
442 5     5 1 81 my($self, %opts) = @_;
443 5         22 my $directory = delete $opts{directory};
444 5   50     71 my $origin = delete $opts{origin} || 'origin';
445 5         22 my $method = delete $opts{method};
446 5 50       23 error "Unhandled options: " . join(' ', %opts) if %opts;
447              
448 5 100       42 my @methods = (
    100          
449             ref $method eq 'ARRAY' ? @$method :
450             defined $method ? $method :
451             ()
452             );
453 5 100       20 if (!@methods) { @methods = 'remote' }
  1         10  
454              
455 5         71 my @error_msgs;
456             my $res;
457              
458             in_directory {
459 5     5   24 TRY_METHODS: while (@methods) {
460 5         13 my $method = shift @methods;
461 5 100       28 if ($method eq 'remote') {
    100          
462             # from https://stackoverflow.com/questions/28666357/git-how-to-get-default-branch#comment126528129_50056710
463 2         37 chomp(my $info_res = $self->info_qx({quiet=>1}, qw(env LC_ALL=C git remote show), $origin));
464 2 50       84 if ($info_res =~ /^\s*HEAD branch:\s+(.*)/m) {
465 2         30 $res = $1;
466 2         70 last TRY_METHODS;
467             } else {
468 0         0 push @error_msgs, "method $method: Can't get default branch; git-remote output is:\n$res";
469             }
470             } elsif ($method eq 'symbolic-ref') {
471 2         10 my $parent_ref = 'refs/remotes/' . $origin;
472 2         4 chomp(my $info_res = eval { $self->info_qx({quiet=>1}, qw(git symbolic-ref), "$parent_ref/HEAD") });
  2         29  
473 2 50 33     60 if (defined $info_res && $info_res ne '') {
474 2         17 $res = substr($info_res, length($parent_ref)+1);
475 2         67 last TRY_METHODS;
476             } else {
477 0         0 push @error_msgs, "method $method: Can't get default branch ($@)";
478             }
479             } else {
480 1         27 error "Unhandled git_get_default_branch method '$method'";
481             }
482             }
483 5         95 } $directory;
484              
485 4 50       134 if (@error_msgs) {
486 0         0 error join("\n", @error_msgs);
487             }
488              
489 4         157 $res;
490             }
491              
492              
493             # From https://stackoverflow.com/a/4495524/2332415
494             sub _is_dir_empty {
495 2     2   27 my ($dir) = @_;
496              
497 2 50       144 opendir my $h, $dir
498             or error "Cannot open directory: '$dir': $!";
499              
500 2         65 while (defined (my $entry = readdir $h)) {
501 5 100       111 return unless $entry =~ /^[.][.]?\z/;
502             }
503              
504 1         35 return 1;
505             }
506              
507             sub _pipe_open (@) {
508 139     139   1282 my(@cmd) = @_;
509 139         315 my $fh;
510 139         293 if (Doit::IS_WIN && $] < 5.022) {
511             open $fh, '-|', Doit::Win32Util::win32_quote_list(@cmd)
512             or return undef;
513             } else {
514 139 50       484508 open $fh, '-|', @cmd
515             or return undef;
516             }
517 139         10428 return $fh;
518             }
519              
520             1;
521              
522             __END__