File Coverage

blib/lib/CPAN/InGit.pm
Criterion Covered Total %
statement 108 236 45.7
branch 22 108 20.3
condition 4 29 13.7
subroutine 19 25 76.0
pod 8 9 88.8
total 161 407 39.5


line stmt bran cond sub pod time code
1             package CPAN::InGit;
2              
3             our $VERSION = '0.003'; # VERSION
4             # ABSTRACT: Manage custom CPAN trees to pin versions for your projects
5              
6 5     5   2257803 use Git::Raw::Repository;
  5         29115  
  5         199  
7 5     5   4090 use Archive::Tar;
  5         483008  
  5         504  
8 5     5   85 use Archive::Tar::Constant; # for constants to be avilable at compile time
  5         15  
  5         1135  
9 5     5   41 use Scalar::Util 'blessed';
  5         14  
  5         327  
10 5     5   3456 use CPAN::InGit::MirrorTree;
  5         25  
  5         292  
11 5     5   2708 use CPAN::Meta 2.150010;
  5         138930  
  5         323  
12 5     5   3771 use Module::Metadata;
  5         45034  
  5         349  
13 5     5   86 use Fcntl qw( S_ISREG S_ISLNK S_IFMT S_ISDIR );
  5         14  
  5         428  
14 5     5   33 use Carp;
  5         12  
  5         357  
15 5     5   32 use Moo;
  5         11  
  5         48  
16 5     5   3932 use v5.36;
  5         40  
17              
18              
19 5     5 0 919139 sub BUILDARGS($class, @list) {
  5         18  
  5         35  
  5         10  
20 5 50       29 unshift @list, 'git_repo' if @list == 1;
21 5         62 my $args= $class->next::method(@list);
22 5 100       202 $args->{git_repo}= delete $args->{repo} if defined $args->{repo};
23 5         129 $args;
24             }
25              
26             has git_repo => ( is => 'ro', required => 1, coerce => \&_open_repo );
27             has git_author_name => ( is => 'rw', default => 'CPAN::InGit' );
28             has git_author_email => ( is => 'rw', default => 'CPAN::InGit@localhost' );
29              
30             has workdir_branch_name => ( is => 'lazy' );
31 0     0   0 sub _build_workdir_branch_name($self) {
  0         0  
  0         0  
32 0 0 0     0 return undef if $self->git_repo->is_bare || $self->git_repo->is_head_detached;
33 0         0 return $self->git_repo->head->shorthand;
34             }
35              
36             has useragent => ( is => 'lazy' );
37 0     0   0 sub _build_useragent($self) {
  0         0  
  0         0  
38 0         0 require Mojo::UserAgent;
39 0         0 return Mojo::UserAgent->new;
40             }
41              
42 5     5   181 sub _open_repo($thing) {
  5         13  
  5         11  
43 5 50 33     216 return $thing if blessed($thing) && $thing->isa('Git::Raw::Repository');
44 0         0 return Git::Raw::Repository->open("$thing");
45             }
46              
47              
48 4     4 1 40 sub get_archive_tree($self, $branch_or_tag_or_id) {
  4         7  
  4         8  
  4         23  
49 4         15 my ($tree, $origin)= $self->lookup_tree($branch_or_tag_or_id);
50 4 50       56 return undef unless $tree;
51              
52 4 50 33     82 my $branch= $origin && ref($origin)->isa('Git::Raw::Branch')? $origin : undef;
53              
54             # If HEAD requested or using the branch pointed to by HEAD, and if it has a work directory,
55             # then apply any changes to the workdir.
56 4   33     33 my $use_workdir= !$self->git_repo->is_bare && (($branch && $branch->is_head) || $branch_or_tag_or_id eq 'HEAD');
57              
58             # Does it look like an ArchiveTree?
59 4         7 my $config_blob;
60 4 50       9 if ($use_workdir) {
61 0         0 my $ent= $self->git_repo->index->find('cpan_ingit.json');
62 0 0       0 return undef unless $ent;
63 0         0 $config_blob= $ent->blob;
64             } else {
65 4         34 my $ent= $tree->entry_bypath('cpan_ingit.json');
66 4 50       16 return undef unless $ent;
67 4         357 $config_blob= $ent->object;
68             }
69 4         39 my $cfg= JSON::PP->new->relaxed->decode($config_blob->content);
70 4 50       3126 my $class= $cfg->{upstream_url}? 'CPAN::InGit::MirrorTree' : 'CPAN::InGit::ArchiveTree';
71 4         109 return $class->new(
72             parent => $self,
73             tree => $tree,
74             use_workdir => $use_workdir,
75             (branch => $branch)x!!$branch,
76             );
77             }
78              
79              
80 1     1 1 8 sub create_archive_tree($self, $name, %params) {
  1         34  
  1         2  
  1         2  
  1         2  
81 1 50       64 croak "Branch '$name' already exists"
82             if Git::Raw::Branch->lookup($self->git_repo, $name, 1);
83 1 50       46 croak "Branch '$name' already exists upstream"
84             if Git::Raw::Branch->lookup($self->git_repo, $name, 0);
85 1         2 my $t;
86 1 50       5 if ($params{upstream_url}) {
87 0         0 $t= CPAN::InGit::MirrorTree->new(%params, parent => $self);
88 0         0 $t->add_upstream_package_details;
89             } else {
90 1         41 $t= CPAN::InGit::ArchiveTree->new(%params, parent => $self);
91 1         5 $t->write_package_details;
92             }
93             # It won't exist until we create a commit and create a branch.
94 1         6 $t->write_config;
95 1         8 $t->commit("Called create_archive_tree", create_branch => $name);
96 1         10 return $t;
97             }
98              
99              
100 9     9 1 76 sub lookup_tree($self, $branch_or_tag_or_id) {
  9         19  
  9         20  
  9         15  
101 9         17 my ($tree, $origin);
102 9 50       46 defined $branch_or_tag_or_id or croak "missing argument";
103 9         43 my $repo= $self->git_repo;
104 9 50 0     54 if (blessed($branch_or_tag_or_id) && (
    50 33        
    50          
    0          
    0          
105             $branch_or_tag_or_id->isa('Git::Raw::Branch')
106             || $branch_or_tag_or_id->isa('Git::Raw::Tag')
107             )) {
108 0         0 $origin= $branch_or_tag_or_id;
109 0         0 $tree= $origin->peel('tree');
110             } elsif ($branch_or_tag_or_id eq 'HEAD') {
111 0         0 $tree= $repo->head->target->peel('tree');
112 0 0       0 $origin= $repo->is_head_detached? undef
113             : $repo->head;
114 9         933 } elsif ($origin= eval { Git::Raw::Branch->lookup($repo, $branch_or_tag_or_id, 1) }) {
115 9         195 $tree= $origin->peel('tree');
116 0         0 } elsif ($origin= eval { Git::Raw::Tag->lookup($repo, $branch_or_tag_or_id) }) {
117 0         0 $tree= $origin->peel('tree');
118 0         0 } elsif (my $obj= eval { $repo->lookup($branch_or_tag_or_id) }) {
119 0 0       0 if ($obj->type == Git::Raw::Object::COMMIT()) {
    0          
    0          
120 0         0 $origin= Git::Raw::Commit->lookup($repo, $obj->id);
121 0         0 $tree= $origin->tree;
122             } elsif ($obj->type == Git::Raw::Object::TREE()) {
123 0         0 $tree= Git::Raw::Tree->lookup($repo, $obj->id);
124             } elsif ($obj->type == Git::Raw::Object::TAG()) {
125 0         0 $origin= Git::Raw::Tag->lookup($repo, $obj->id);
126 0         0 $tree= $origin->target;
127             }
128             }
129 9 100       83 return wantarray? ($tree, $origin) : $tree;
130             }
131              
132              
133 1     1 1 20 sub add_git_tree_to_tar($self, $tar, $path, $tree) {
  1         3  
  1         31  
  1         7  
  1         3  
  1         1  
134 1 50       15 unless ($tree->can('entries')) {
135 0         0 my $id= $tree;
136 0 0       0 $tree = Git::Raw::Tree->lookup($self->git_repo, $id)
137             or die "Can't find TREE $id referenced by '$path'";
138             }
139             $self->add_git_dirent_to_tar($tar, "$path/".$_->name, $_)
140 1         20 for $tree->entries;
141             }
142              
143 1     1 1 2 sub add_git_dirent_to_tar($self, $tar, $path, $dirent) {
  1         2  
  1         2  
  1         2  
  1         3  
  1         2  
144 1 50       13 if ($dirent->type == Git::Raw::Object::BLOB()) {
    0          
145 1         50 my $mode = $dirent->file_mode;
146 1 50       131 my $blob = Git::Raw::Blob->lookup($self->git_repo, $dirent->id)
147             or die "Can't find BLOB ".$dirent->id." referenced by '$path'";
148             # Check if it's a symlink (mode 0120000 or 40960 decimal)
149 1 50       21 if (($mode & 0170000) == 0120000) {
150             # Symlink: content is the target path
151 0         0 $tar->add_data($path, $blob->content, {
152             mode => $mode,
153             type => Archive::Tar::Constant::SYMLINK,
154             linkname => $blob->content
155             });
156             }
157             else {
158             # Regular file
159 1         13 $tar->add_data($path, $blob->content, { mode => $mode });
160             }
161             }
162             elsif ($dirent->type == Git::Raw::Object::TREE()) {
163 0         0 $self->add_git_tree_to_tar($tar, $path, $dirent->id);
164             }
165             else {
166 0         0 warn "Omitting $path from TAR, not a BLOB or TREE";
167             }
168             }
169              
170              
171 7     7 1 16 sub new_signature($self) {
  7         13  
  7         14  
172 7         327 Git::Raw::Signature->now($self->git_author_name, $self->git_author_email);
173             }
174              
175              
176 0     0 1   sub lookup_versions($self, $module_name) {
  0            
  0            
  0            
177 0           for my $up ($self->upstream_mirrors->@*) {
178 0           ...;
179             }
180             }
181              
182              
183 0     0 1   sub process_distfile($self, %opts) {
  0            
  0            
  0            
184 0           my ($tree, $file_path, $file_data, $extract)= @opts{'tree','file_path','file_data','extract'};
185             # Decompress tar in memory. The decompression gets complicated since it can be bz2 or gz
186             # so write to a temp file and then parse that with the auto-detection of "compress".
187 0 0         if ($file_path =~ /\.(tar\.gz|tar\.bz2|tgz)\z/) {
188 0           my $path_without_extension= substr($file_path, 0, $-[0]);
189 0           my $tmp= File::Temp->new;
190 0 0         $tmp->print($$file_data) or die "write: $!";
191 0           $tmp->flush;
192              
193             # Iterate across the files in the tar archive
194 0           my $tar= Archive::Tar->new("$tmp", 1);
195 0           my @files= $tar->get_files;
196 0 0         if (!@files) {
197 0           croak "Failed to extract any files from archive $file_path";
198             }
199             # Remove prefix directory if every file in archive starts with the same directory
200 0           (my $prefix= $files[0]->name) =~ s,/.*,,;
201 0           $prefix .= '/';
202 0           for (@files) {
203 0 0         if (substr($_->name,0,length $prefix) ne $prefix) {
204 0           $prefix= '';
205 0           last;
206             }
207             }
208             # Build by-name hash of files
209 0           my %files= map +( substr($_->name, length $prefix) => $_ ), @files;
210 0           my $meta;
211             # Look for a META.json
212 0 0         if (my $meta_json= $files{'META.json'}) {
213 0 0         eval {
214 0           my $cm= CPAN::Meta->load_json_string($meta_json->get_content);
215 0           $meta= $cm->as_struct({ version => 2 });
216             } or warn "Failed to load $file_path/${prefix}META.json: $@";
217             }
218             # else look for META.yml
219 0 0 0       if (!$meta && (my $meta_yml= $files{'META.yml'})) {
220 0 0         eval {
221 0           my $cm= CPAN::Meta->load_yaml_string($meta_yml->get_content);
222 0           $meta= $cm->as_struct({ version => 2 });
223             } or warn "Failed to load $file_path/${prefix}META.yml: $@";
224             }
225             # TODO: add some fall-back that guesses at prereqs.
226 0   0       $meta //= {};
227             # If the meta didn't contain "provides", add that using Module::Metadata
228 0 0         if (!$meta->{provides}) {
229 0           my $provides= $meta->{provides}= {};
230 0   0       for my $pm_fname (grep /\.pm\z/ && !m{^(t|xt|inc|script|bin)/}, keys %files) {
231 0 0         eval {
232 0 0         open my $pm_fh, '<', $files{$pm_fname}->get_content_by_ref or die;
233 0           my $mm= Module::Metadata->new_from_handle($pm_fh, $pm_fname);
234 0           for my $pkg (grep $_ ne 'main', $mm->name, $mm->packages_inside) {
235 0           $provides->{$pkg}{file}= $pm_fname;
236 0   0       $provides->{$pkg}{version} //= $mm->version($pkg);
237             }
238 0           1;
239             } or warn "Failed to parse packages in $file_path/${prefix}$pm_fname: $@";
240             }
241             }
242             # If caller requests 'extract', add the tar's files and symlinks to the tree
243 0 0         if ($extract) {
244 0           for (keys %files) {
245 0           my $mode= $files{$_}->mode;
246 0 0         if (S_ISREG($mode)) {
    0          
    0          
247             # normalize to 644 or 755
248 0 0         $mode= S_IFMT($mode) | (($mode & 1)? 0755 : 0644);
249 0           $tree->set_path("$path_without_extension/$_", $files{$_}->get_content_by_ref, $mode);
250             } elsif (S_ISLNK($mode)) {
251 0           $tree->set_path("$path_without_extension/$_", \$files{$_}->linkname, S_IFMT($mode));
252             } elsif (!S_ISDIR($mode)) {
253 0           warn "Skipping tar entry for '$path_without_extension/$_' (mode=$mode)\n";
254             }
255             }
256             } else {
257 0           $tree->set_path($file_path, $file_data);
258             }
259             # Now serialzie the meta and write it to the tree alongside the TAR
260 0           my $json= CPAN::Meta->new($meta)->as_string({ version => 2 });
261 0           $tree->set_path($path_without_extension . '.json', \$json);
262             }
263             else {
264 0           warn "$file_path does not appear to be a TAR file. Skipping metadata processing.";
265 0           $tree->set_path($file_path, $file_data);
266             }
267             }
268              
269             #=method parse_cpanfile_snapshot
270             #
271             # $distribution_spec= $cpan_repo->parse_cpanfile_snapshot($file_contents);
272             #
273             #Given a scalar with the content of a cpanfile.snapshot from L, this returns the data of
274             #that file as a hierarchial structure:
275             #
276             # {
277             # "Distribution-Name-1.002003" => {
278             # "pathname" => "A/AU/AUTHOR/Distribution-Name-1.002003.tar.gz",
279             # "provides" => {
280             # "Distribution::Name" => '1.002003',
281             # ...
282             # },
283             # "requirements" => {
284             # "Dependency" => "2.05",
285             # ...
286             # }
287             # }
288             # }
289             #
290             #=cut
291              
292             sub _context {
293 0     0     my $context= substr($_, pos($_), $_[0]);
294 0           $context =~ s/\r/\\r/g;
295 0           $context =~ s/\n/\\n/g;
296 0           return $context;
297             }
298 0     0     sub _parse_cpanfile_snapshot($self, $text) {
  0            
  0            
  0            
299             # TODO: use official module if available, else fall back to this:
300 0           my %distributions;
301 0           local $_= $text;
302 0 0         unless (eval {
303 0 0         /^# carton snapshot format: version 1.0\r?\n/mgc
304             or die "Unsupported cpanfile.snapshot version\n";
305 0 0         /\GDISTRIBUTIONS\r?\n/gc
306             or die "expected DISTRIBUTIONS\n";
307 0           while (length > pos) {
308 0 0         my ($dist_name)= /\G (\S+)\r?\n/gc
309             or die "expected dist name\n";
310 0           my $dist= $distributions{$dist_name}= {};
311 0           while (/\G (\w[^\r\n:]*): *(.*?)\r?\n/gc) {
312 0           my ($attr, $val)= ($1, $2);
313 0 0         if (length $val) {
314 0           $dist->{$attr}= $val;
315             } else {
316 0           while (/\G (.*)\r?\n/gc) {
317 0           push @{ $dist->{$attr} }, $1;
  0            
318             }
319             }
320 0 0         die "Unexpected sub-element of $dist_name $attr\n"
321             if /\G /gc;
322             }
323 0 0         die "Unexpected sub-element of $dist_name\n"
324             if /\G /gc;
325             # convert 'provides' and 'requirements' to hashrefs of versions
326 0           for (qw( provides requirements )) {
327 0 0         if (ref $dist->{$_} eq 'ARRAY') {
328 0           $dist->{$_}= { map +((split ' ')[0,1]), @{$dist->{$_}} };
  0            
329             }
330             }
331             }
332 0           1;
333             }) {
334 0           chomp $@;
335 0           my $context= _context(20);
336 0           croak "syntax error: $@, near \"$context\"";
337             }
338 0           \%distributions;
339             }
340              
341              
342             1;
343              
344             __END__