File Coverage

blib/lib/Doit/Git.pm
Criterion Covered Total %
statement 237 273 86.8
branch 131 178 73.6
condition 25 31 80.6
subroutine 26 26 100.0
pod 9 11 81.8
total 428 519 82.4


line stmt bran cond sub pod time code
1             # -*- perl -*-
2              
3             #
4             # Author: Slaven Rezic
5             #
6             # Copyright (C) 2017,2018,2019 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         63  
17 2     2   11 use warnings;
  2         5  
  2         90  
18             our $VERSION = '0.027';
19              
20 2     2   10 use Doit::Log;
  2         4  
  2         125  
21 2     2   38 use Doit::Util qw(in_directory);
  2         26  
  2         5954  
22              
23             sub _pipe_open (@);
24              
25 2     2 0 25 sub new { bless {}, shift }
26 2     2 0 9 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) }
27              
28             sub git_repo_update {
29 39     39 1 901 my($self, %opts) = @_;
30 39         183 my $repository = delete $opts{repository};
31 39 100       128 my @repository_aliases = @{ delete $opts{repository_aliases} || [] };
  39         631  
32 39         132 my $directory = delete $opts{directory};
33 39   100     716 my $origin = delete $opts{origin} || 'origin';
34 39         298 my $branch = delete $opts{branch};
35 39         106 my $allow_remote_url_change = delete $opts{allow_remote_url_change};
36 39         92 my $clone_opts = delete $opts{clone_opts};
37 39   100     284 my $refresh = delete $opts{refresh} || 'always';
38 39 100       752 if ($refresh !~ m{^(always|never)$}) { error "refresh may be 'always' or 'never'" }
  1         5  
39 38         116 my $quiet = delete $opts{quiet};
40 38 100       206 error "Unhandled options: " . join(" ", %opts) if %opts;
41              
42 37         128 my $has_changes = 0;
43 37         75 my $do_clone;
44 37 100       948 if (!-e $directory) {
45 12         84 $do_clone = 1;
46             } else {
47 25 100       444 if (!-d $directory) {
48 1         15 error "'$directory' exists, but is not a directory\n";
49             }
50 24 100       542 if (!-d "$directory/.git") {
51 2 100       26 if (_is_dir_empty($directory)) {
52 1         11 $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 35 100       158 if (!$do_clone) {
59             in_directory {
60 22     22   76 my $actual_repository = eval { $self->info_qx({quiet=>1}, qw(git config --get), "remote.$origin.url") };
  22         338  
61 22 100       270 if (!defined $actual_repository) {
62             # Remote does not exist yet --- create it.
63 1         56 $self->system(qw(git remote add), $origin, $repository);
64             } else {
65 21         81 chomp $actual_repository;
66 21 100 100     209 if ($actual_repository ne $repository && !grep { $_ eq $actual_repository } @repository_aliases) {
  2         35  
67 3         58 my @change_cmd = ('git', 'remote', 'set-url', $origin, $repository);
68 3 100       24 if ($allow_remote_url_change) {
69 1         42 info "Need to change remote URL for $origin";
70 1         365 $self->system(@change_cmd);
71             } else {
72 2 100       83 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 20         194 my $switch_later;
82 20 100       108 if (defined $branch) { # maybe branch switching necessary?
83 9         188 my $current_branch = $self->git_current_branch;
84 9 100 66     174 if (!defined $current_branch || $current_branch ne $branch) {
85 5 100       43 if (eval { $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout), $branch); 1 }) {
  5         162  
  2         156  
86 2         18 $has_changes = 1;
87             } else {
88             # Cannot switch now to the branch. Maybe a
89             # git-fetch has to be done first, as the
90             # branch is not yet in the clone --- try
91             # later.
92 3         43 $switch_later = 1;
93             }
94             }
95 9         166 my %info;
96 9         195 $self->git_current_branch(info_ref => \%info);
97 9 100       387 if ($info{detached}) {
98 3         23 $switch_later = 1; # because a "git pull" wouldn't update a detached branch
99             }
100             }
101              
102 20 100       109 if ($refresh eq 'always') {
103 19         516 $self->system({show_cwd=>1,quiet=>$quiet}, qw(git fetch), $origin);
104 19         1984 my $status = $self->git_short_status(untracked_files => 'no');
105 19 100       381 if ($status =~ m{>$}) {
106             # may actually fail if diverged (status=<>)
107             # or untracked/changed files would get overwritten
108 5         233 $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...
109 4         668 $has_changes = 1;
110             } # else: ahead, diverged, or something else
111             }
112              
113 19 100       579 if ($switch_later) {
114 6         45 my($commit_before, $branch_before);
115 6 100       47 if (!$has_changes) {
116 5         171 $commit_before = $self->git_get_commit_hash;
117 5         110 $branch_before = $self->git_current_branch;
118             }
119 6 100       42 if (!eval { $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout), $branch) }) {
  6         282  
120             # Possible reason for the failure: $branch exists
121             # as a remote branch in multiple remotes. Try
122             # again by explicitly specifying the remote.
123             # --track exists since approx git 1.5.1
124 1         51 $self->system({show_cwd=>1,quiet=>$quiet}, qw(git checkout -b), $branch, qw(--track), "$origin/$branch");
125             }
126 6 100 100     397 if ($commit_before
      100        
127             && ( $self->git_get_commit_hash ne $commit_before
128             || $self->git_current_branch ne $branch_before
129             )
130             ) {
131 4         147 $has_changes = 1;
132             }
133             }
134 22         585 } $directory;
135             } else {
136 13         158 my @cmd = (qw(git clone --origin), $origin);
137 13 100       79 if (defined $branch) {
138 1         17 push @cmd, "--branch", $branch;
139             }
140 13 100       93 if ($clone_opts) {
141 1         19 push @cmd, @$clone_opts;
142             }
143 13         64 push @cmd, $repository, $directory;
144 13         133 $self->system(@cmd);
145 13         1024 $has_changes = 1;
146             }
147 32         2978 $has_changes;
148             }
149              
150             sub git_short_status {
151 57     57 1 764 my($self, %opts) = @_;
152 57         332 my $directory = delete $opts{directory};
153 57         172 my $untracked_files = delete $opts{untracked_files};
154 57 100       1007 if (!defined $untracked_files) {
    100          
155 21         121 $untracked_files = 'normal';
156             } elsif ($untracked_files !~ m{^(normal|no)$}) {
157 1         4 error "only values 'normal' or 'no' supported for untracked_files";
158             }
159 56 100       245 error "Unhandled options: " . join(" ", %opts) if %opts;
160              
161             in_directory {
162 55     55   1287 local $ENV{LC_ALL} = 'C';
163              
164 55         163 my $untracked_marker = '';
165             {
166 55         314 my @cmd = ("git", "status", "--untracked-files=$untracked_files", "--porcelain");
167 55 50       197 my $fh = _pipe_open(@cmd)
168             or error "Can't run '@cmd': $!";
169 55         421 my $has_untracked;
170             my $has_uncommitted;
171 55         136828 while (<$fh>) {
172 13 100       344 if (m{^\?\?}) {
173 6         43 $has_untracked++;
174             } else {
175 7         56 $has_uncommitted++;
176             }
177             # Shortcut, exit as early as possible
178 13 100       101 if ($has_uncommitted) {
179 8 100       125 if ($has_untracked) {
    100          
180 1         169 return '<<*';
181             } elsif ($untracked_files eq 'no') {
182 3         572 return '<<';
183             } # else we have to check further, for possible untracked files
184             }
185             }
186 51 100       352 if ($has_uncommitted) {
    100          
187 3         533 return '<<';
188             } elsif ($has_untracked) {
189 5         39 $untracked_marker = '*'; # will be combined later
190 5         225 last;
191             }
192 43 100       2443 close $fh
193             or error "Error while running '@cmd': $!";
194             }
195              
196             {
197 55         108 my @cmd = ("git", "status", "--untracked-files=no");
  46         290  
  46         593  
198 46 50       375 my $fh = _pipe_open(@cmd)
199             or error "Can't run '@cmd': $!";
200 46         175 my $l;
201 46         132951 $l = <$fh>;
202 46         337 $l = <$fh>;
203 46 100       4301 if ($l =~ m{^(# )?Your branch is ahead}) {
    100          
    100          
204 4         883 return '<'.$untracked_marker;
205             } elsif ($l =~ m{^(# )?Your branch is behind}) {
206 9         1508 return $untracked_marker.'>';
207             } elsif ($l =~ m{^(# )?Your branch and .* have diverged}) {
208 4         799 return '<'.$untracked_marker.'>';
209             }
210             }
211              
212 29 50       741 if (-f ".git/svn/.metadata") {
213             # simple-minded heuristics, works only with svn standard branch
214             # layout
215 0         0 my $root_dir = $self->git_root;
216 0 0       0 if (open my $fh_remote, "$root_dir/.git/refs/remotes/trunk") {
217 0 0       0 if (open my $fh_local, "$root_dir/.git/refs/heads/master") {
218 0         0 chomp(my $sha1_remote = <$fh_remote>);
219 0         0 chomp(my $sha1_local = <$fh_local>);
220 0 0       0 if ($sha1_remote ne $sha1_local) {
221 0         0 my $remote_is_newer;
222 0 0       0 if (my $log_fh = _pipe_open('git', 'log', '--pretty=format:%H', 'master..remotes/trunk')) {
223 0 0       0 if (scalar <$log_fh>) {
224 0         0 $remote_is_newer = 1;
225             }
226             }
227 0         0 my $local_is_newer;
228 0 0       0 if (my $log_fh = _pipe_open('git', 'log', '--pretty=format:%H', 'remotes/trunk..master')) {
229 0 0       0 if (scalar <$log_fh>) {
230 0         0 $local_is_newer = 1;
231             }
232             }
233 0 0 0     0 if ($remote_is_newer && $local_is_newer) {
    0          
    0          
234 0         0 return '<'.$untracked_marker.'>';
235             } elsif ($remote_is_newer) {
236 0         0 return $untracked_marker.'>';
237             } elsif ($local_is_newer) {
238 0         0 return '<'.$untracked_marker;
239             } else {
240 0         0 return '?'; # Should never happen
241             }
242             }
243             }
244             }
245             }
246              
247 29         3076 return $untracked_marker;
248              
249 55         1590 } $directory;
250             }
251              
252             sub git_root {
253 55     55 1 257 my($self, %opts) = @_;
254 55         133 my $directory = delete $opts{directory};
255 55 100       172 error "Unhandled options: " . join(" ", %opts) if %opts;
256              
257             in_directory {
258 54     54   643 chomp(my $dir = $self->info_qx({quiet=>1}, 'git', 'rev-parse', '--show-toplevel'));
259 47         2237 $dir;
260 54         713 } $directory;
261             }
262              
263             sub git_get_commit_hash {
264 20     20 1 238 my($self, %opts) = @_;
265 20         66 my $directory = delete $opts{directory};
266 20         53 my $commit = delete $opts{commit};
267 20 100       111 error "Unhandled options: " . join(" ", %opts) if %opts;
268              
269             in_directory {
270 19 100   19   359 chomp(my $commit = $self->info_qx({quiet=>1}, 'git', 'log', '-1', '--format=%H', (defined $commit ? $commit : ())));
271 17         915 $commit;
272 19         441 } $directory;
273             }
274              
275             sub git_get_commit_files {
276 8     8 1 89 my($self, %opts) = @_;
277 8         26 my $directory = delete $opts{directory};
278 8 100       27 my $commit = delete $opts{commit}; if (!defined $commit) { $commit = 'HEAD' }
  8         34  
  6         26  
279 8 100       41 error "Unhandled options: " . join(" ", %opts) if %opts;
280              
281 7         10 my @files;
282             in_directory {
283 6     6   60 my @cmd = ('git', 'show', $commit, '--pretty=format:', '--name-only');
284 6 50       42 my $fh = _pipe_open(@cmd)
285             or error "Error running @cmd: $!";
286 6         9216 my $first = <$fh>;
287 6 100 66     147 if (defined $first && $first ne "\n") { # first line is empty for older git versions (e.g. 1.7.x)
288 3         22 chomp $first;
289 3         34 push @files, $first;
290             }
291 6         169 while(<$fh>) {
292 1         18 chomp;
293 1         119 push @files, $_;
294             }
295 6 100       400 close $fh
296             or error "Error while running @cmd: $!";
297 7         135 } $directory;
298 3         205 @files;
299             }
300              
301             sub git_get_changed_files {
302 11     11 1 108 my($self, %opts) = @_;
303 11         56 my $directory = delete $opts{directory};
304 11         22 my $ignore_untracked = delete $opts{ignore_untracked};
305 11 100       42 error "Unhandled options: " . join(" ", %opts) if %opts;
306              
307 10         51 my @files;
308             in_directory {
309 10     10   58 my @cmd = qw(git status --porcelain);
310 10 50       37 my $fh = _pipe_open(@cmd)
311             or error "Error running @cmd: $!";
312 10         17811 while(<$fh>) {
313 7         49 chomp;
314 7 100 66     151 next if $ignore_untracked && m{^\?\?};
315 6         104 s{^...}{};
316 6         171 push @files, $_;
317             }
318 10 100       651 close $fh
319             or error "Error while running @cmd: $!";
320 10         232 } $directory;
321 8         584 @files;
322             }
323              
324             sub git_is_shallow {
325 5     5 1 66 my($self, %opts) = @_;
326 5         17 my $directory = delete $opts{directory};
327 5 100       30 error "Unhandled options: " . join(" ", %opts) if %opts;
328              
329 4         56 my $git_root = $self->git_root(directory => $directory);
330 2 100       132 -f "$git_root/.git/shallow" ? 1 : 0;
331             }
332              
333             sub git_current_branch {
334 43     43 1 617 my($self, %opts) = @_;
335 43         137 my $directory = delete $opts{directory};
336 43         96 my $info_ref = delete $opts{info_ref};
337 43 100       292 error "Unhandled options: " . join(" ", %opts) if %opts;
338              
339             in_directory {
340 42     42   345 my $git_root = $self->git_root;
341 40         160 my $fh;
342             my $this_head;
343 40 50       2444 if (open $fh, "<", "$git_root/.git/HEAD") {
344 40         642 chomp($this_head = <$fh>);
345 40 100       951 if ($this_head =~ m{refs/heads/(\S+)}) {
346 27         1619 return $1;
347             }
348             }
349              
350             # fallback to git-status
351 13         246 $ENV{LC_ALL} = 'C';
352 13 50       107 if ($fh = _pipe_open(qw(git status))) {
353 13         38702 chomp($_ = <$fh>);
354 13 50       271 if (/^On branch (.*)/) {
355 0 0       0 if ($info_ref) {
356 0         0 $info_ref->{fallback} = 'git-status';
357             }
358 0         0 return $1;
359             }
360 13 50       360 if (/^.* detached at (.*)/) {
361 13 100       80 if ($info_ref) {
362 7         92 $info_ref->{detached} = 1;
363 7         82 $info_ref->{fallback} = 'git-status';
364             }
365 13         1587 return $1;
366             }
367 0 0       0 if (/^\Q# Not currently on any branch./) {
368             # Probably old git (~ 1.5 ... 1.7)
369 0 0       0 if (my $fh2 = _pipe_open(qw(git show-ref))) {
370 0         0 while(<$fh2>) {
371 0         0 chomp;
372 0 0       0 if (my($sha1, $ref) = $_ =~ m{^(\S+)\s+refs/remotes/(.*)$}) {
373 0 0       0 if ($sha1 eq $this_head) {
374 0 0       0 if ($info_ref) {
375 0         0 $info_ref->{detached} = 1;
376 0         0 $info_ref->{fallback} = 'git-show-ref';
377             }
378 0         0 return $ref;
379             }
380             }
381             }
382 0 0       0 close $fh2
383             or warning "Problem while running 'git show-ref': $!";
384             } else {
385 0         0 warning "Error running 'git show-ref': $!";
386             }
387             }
388             }
389              
390 0         0 undef;
391 42         1110 } $directory;
392             }
393              
394             sub git_config {
395 23     23 1 348 my($self, %opts) = @_;
396 23         62 my $directory = delete $opts{directory};
397 23         124 my $key = delete $opts{key};
398 23         50 my $val = delete $opts{val};
399 23         35 my $unset = delete $opts{unset};
400 23 100       72 error "Unhandled options: " . join(" ", %opts) if %opts;
401 22 100 100     121 if (defined $val && $unset) {
402 1         30 error "Don't specify both 'unset' and 'val'";
403             }
404              
405             in_directory {
406 2     2   17 no warnings 'uninitialized'; # $old_val may be undef
  2         5  
  2         873  
407 21     21   36 chomp(my($old_val) = eval { $self->info_qx({quiet=>1}, qw(git config), $key) });
  21         233  
408 21 100       306 if ($unset) {
409 2 100       80 if ($@) {
410 1 50       22 if ($@->{exitcode} == 1) {
411             # already non-existent (or even invalid)
412 1         61 0;
413             } else {
414 0         0 error "git config $key failed with exitcode $@->{exitcode}";
415             }
416             } else {
417 1 50       31 $self->system(qw(git config --unset), $key, (defined $val ? $val : ()));
418 1         138 1;
419             }
420             } else {
421 19 100       67 if (!defined $val) {
422 12         925 $old_val;
423             } else {
424 7 100 100     87 if (!defined $old_val || $old_val ne $val) {
425 6         100 $self->system(qw(git config), $key, $val);
426 4         537 1;
427             } else {
428 1         129 0;
429             }
430             }
431             }
432 21         351 } $directory;
433             }
434              
435             # From https://stackoverflow.com/a/4495524/2332415
436             sub _is_dir_empty {
437 2     2   15 my ($dir) = @_;
438              
439 2 50       159 opendir my $h, $dir
440             or error "Cannot open directory: '$dir': $!";
441              
442 2         46 while (defined (my $entry = readdir $h)) {
443 5 100       71 return unless $entry =~ /^[.][.]?\z/;
444             }
445              
446 1         37 return 1;
447             }
448              
449             sub _pipe_open (@) {
450 130     130   472 my(@cmd) = @_;
451 130         239 my $fh;
452 130         182 if (Doit::IS_WIN && $] < 5.022) {
453             open $fh, '-|', Doit::Win32Util::win32_quote_list(@cmd)
454             or return undef;
455             } else {
456 130 50       275560 open $fh, '-|', @cmd
457             or return undef;
458             }
459 130         6160 return $fh;
460             }
461              
462             1;
463              
464             __END__