File Coverage

blib/lib/GitStore.pm
Criterion Covered Total %
statement 2 4 50.0
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 4 6 66.6


line stmt bran cond sub pod time code
1             package GitStore;
2             BEGIN {
3 6     6   167216 $GitStore::AUTHORITY = 'cpan:YANICK';
4             }
5             {
6             $GitStore::VERSION = '0.15';
7             }
8             #ABSTRACT: Git as versioned data store in Perl
9              
10 6     6   9212 use Moose;
  0            
  0            
11             use Moose::Util::TypeConstraints;
12             use Git::PurePerl;
13             use Carp;
14              
15             use Path::Class qw/ dir file /;
16              
17             use List::Util qw/ first /;
18              
19             use GitStore::Revision;
20              
21             no warnings qw/ uninitialized /;
22              
23             subtype 'PurePerlActor' =>
24             as 'Git::PurePerl::Actor';
25              
26             coerce PurePerlActor
27             => from 'Str'
28             => via {
29             s/<(.*?)>//;
30             Git::PurePerl::Actor->new( name => $_, email => $1 );
31             };
32              
33             has 'repo' => ( is => 'ro', isa => 'Str', required => 1 );
34              
35             has 'branch' => ( is => 'rw', isa => 'Str', default => 'master' );
36              
37             has author => (
38             is => 'rw',
39             isa => 'PurePerlActor',
40             default => sub {
41             Git::PurePerl::Actor->new(
42             name => 'anonymous',
43             email => 'anon@127.0.0.1'
44             );
45             } );
46              
47             has serializer => (
48             is => 'ro',
49             default => sub {
50             require Storable;
51             return sub { return Storable::nfreeze($_[2]); }
52             },
53             );
54              
55             has deserializer => (
56             is => 'ro',
57             default => sub {
58             require Storable;
59              
60             return sub {
61             my $data = $_[2];
62              
63             my $magic = eval { Storable::read_magic($data); };
64              
65             return $data unless $magic && $magic->{major} && $magic->{major} >= 2 && $magic->{major} <= 5;
66              
67             my $thawed = eval { Storable::thaw($data) };
68              
69             # false alarm... looked like a Storable, but wasn't.
70             return $@ ? $data : $thawed;
71             }
72             },
73             );
74              
75             has autocommit => (
76             is => 'ro',
77             isa => 'Bool',
78             default => 0,
79             );
80              
81             sub _clean_directories {
82             my ( $self, $dir ) = @_;
83              
84             $dir ||= $self->root;
85              
86             my $nbr_files = keys %{ $dir->{FILES} };
87              
88             for my $d ( keys %{ $dir->{DIRS} } ) {
89             if( my $f = $self->_clean_directories( $dir->{DIRS}{$d} ) ) {
90             $nbr_files += $f;
91             }
92             else {
93             delete $dir->{DIRS}{$d};
94             }
95             }
96              
97             return $nbr_files;
98             }
99              
100             sub _expand_directories {
101             my( $self, $object ) = @_;
102              
103             my %dir = ( DIRS => {}, FILES => {} );
104              
105             for my $entry ( map { $_->directory_entries } $object ) {
106             if ( $entry->object->isa( 'Git::PurePerl::Object::Tree' ) ) {
107             $dir{DIRS}{$entry->filename}
108             = $self->_expand_directories( $entry->object );
109             }
110             else {
111             $dir{FILES}{$entry->filename} = $entry->sha1;
112             }
113             }
114              
115             return \%dir;
116             }
117              
118             has 'root' => ( is => 'rw', isa => 'HashRef', default => sub { {} } );
119              
120             has 'git' => (
121             is => 'ro',
122             isa => 'Git::PurePerl',
123             lazy => 1,
124             default => sub {
125             my $repo = $_[0]->repo;
126             return Git::PurePerl->new(
127             ( $repo =~ m/\.git$/ ? 'gitdir' : 'directory') => $repo
128             );
129             }
130             );
131              
132             sub BUILD {
133             my $self = shift;
134            
135             $self->load();
136            
137             }
138              
139             sub BUILDARGS {
140             my $class = shift;
141              
142             if ( @_ == 1 && ! ref $_[0] ) {
143             return { repo => $_[0] };
144             } else {
145             return $class->SUPER::BUILDARGS(@_);
146             }
147             }
148              
149             sub branch_head {
150             my ( $self, $branch ) = @_;
151             $branch ||= $self->branch;
152              
153             return $self->git->ref_sha1('refs/heads/' . $branch);
154             }
155              
156             # Load the current head version from repository.
157             sub load {
158             my $self = shift;
159            
160             my $head = $self->branch_head or do {
161             $self->root({ DIRS => {}, FILES => {} });
162             return;
163             };
164              
165             my $commit = $self->git->get_object($head);
166             my $tree = $commit->tree;
167              
168             my $root = $self->_expand_directories( $tree );
169             $self->root($root);
170              
171             }
172              
173             sub _normalize_path {
174             my ( $self, $path ) = @_;
175              
176             $path = join '/', @$path if ref $path eq 'ARRAY';
177              
178             # Git doesn't like paths prefixed with a '/'
179             $path =~ s#^/+##;
180              
181             return $path;
182             }
183              
184             sub get_revision {
185             my ( $self, $path ) = @_;
186              
187             $path = file( $self->_normalize_path($path) );
188              
189             my $head = $self->branch_head
190             or return;
191              
192             my $commit = $self->git->get_object($head);
193             my @q = ( $commit );
194              
195             my $file = $self->_find_file( $commit->tree, $path )
196             or return;
197              
198             my $latest_file_sha1 = $file->object->sha1;
199             my $last_commit;
200              
201             while ( @q ) {
202             push @q, $q[0]->parents;
203             $last_commit = $commit;
204             $commit = shift @q;
205              
206             my $f = $self->_find_file( $commit->tree, file($path) )
207             or last;
208              
209             last if $f->object->sha1 ne $latest_file_sha1;
210             }
211              
212             return GitStore::Revision->new(
213             gitstore => $self,
214             path => $path,
215             sha1 => $last_commit->sha1,
216             );
217             }
218              
219             sub get {
220             my ( $self, $path ) = @_;
221            
222             $path = file( $self->_normalize_path($path) );
223              
224             my $dir = $self->_cd_dir($path) or return;
225              
226             my $sha1 = $dir->{FILES}{$path->basename} or return;
227              
228             my $object = $self->git->get_object($sha1) or return;
229              
230             return $self->deserializer->($self,$path,$object->content);
231             }
232              
233             sub set {
234             my ( $self, $path, $content ) = @_;
235            
236             $path = file( $self->_normalize_path($path) );
237              
238             my $dir = $self->_cd_dir($path,1) or return;
239              
240             $content = $self->serializer->( $self, $path, $content ) if ref $content;
241              
242             my $blob = Git::PurePerl::NewObject::Blob->new( content => $content );
243             $self->git->put_object($blob);
244              
245             return $dir->{FILES}{$path->basename} = $blob->sha1;
246             }
247              
248             after [ 'set', 'delete' ] => sub {
249             my $self = shift;
250             $self->commit if $self->autocommit;
251             };
252              
253             *remove = \&delete;
254             sub delete {
255             my ( $self, $path ) = @_;
256            
257             $path = file( $self->_normalize_path($path) );
258              
259             my $dir = $self->_cd_dir($path) or return;
260              
261             return delete $dir->{FILES}{$path->basename};
262             }
263              
264             sub _cd_dir {
265             my( $self, $path, $create ) = @_;
266              
267             my $dir = $self->root;
268              
269             for ( grep { !/^\.$/ } $path->dir->dir_list ) {
270             if ( $dir->{DIRS}{$_} ) {
271             $dir = $dir->{DIRS}{$_};
272             }
273             else {
274             return unless $create;
275             $dir = $dir->{DIRS}{$_} = { DIRS => {}, FILES => {} };
276             }
277             }
278              
279             return $dir;
280             }
281              
282             sub _build_new_directory_entry {
283             my( $self, $dir ) = @_;
284              
285             my @children;
286            
287             while ( my( $filename, $sha1 ) = each %{ $dir->{FILES} } ) {
288             push @children,
289             Git::PurePerl::NewDirectoryEntry->new(
290             mode => '100644',
291             filename => $filename,
292             sha1 => $sha1,
293             );
294             }
295              
296             while ( my( $dirname, $dir ) = each %{ $dir->{DIRS} } ) {
297             my $tree = $self->_build_new_directory_entry($dir);
298             push @children, Git::PurePerl::NewDirectoryEntry->new(
299             mode => '040000',
300             filename => $dirname,
301             sha1 => $tree->sha1,
302             );
303             }
304              
305             my $tree = Git::PurePerl::NewObject::Tree->new(
306             directory_entries => \@children,
307             );
308             $self->git->put_object($tree);
309              
310             return $tree;
311             }
312              
313             sub commit {
314             my ( $self, $message ) = @_;
315              
316             unless ( $self->_clean_directories ) {
317             # TODO surely there's a better way?
318             $self->set( '.gitignore/dummy', 'dummy file to keep git happy' );
319             }
320            
321             # TODO only commit if there were changes
322            
323             my $tree = $self->_build_new_directory_entry( $self->root );
324              
325             # there might not be a parent, if it's a new branch
326             my $parent = eval { $self->git->ref( 'refs/heads/'.$self->branch )->sha1 };
327              
328             my $timestamp = DateTime->now;
329             my $commit = Git::PurePerl::NewObject::Commit->new(
330             ( parent => $parent ) x !!$parent,
331             tree => $tree->sha1,
332             author => $self->author,
333             committer => $self->author,
334             comment => $message||'',
335             authored_time => $timestamp,
336             committed_time => $timestamp,
337             );
338             $self->git->put_object($commit);
339              
340             # reload
341             $self->load;
342             }
343              
344             sub discard {
345             my $self = shift;
346              
347             $self->load;
348             }
349              
350             sub _find_file {
351             my( $self, $tree, $path ) = @_;
352              
353             my @path = grep { !/^\.$/ } $path->dir->dir_list;
354              
355             if ( my $part = shift @path ) {
356             my $entry = first { $_->filename eq $part } $tree->directory_entries
357             or return;
358              
359             my $object = $self->git->get_object( $entry->sha1 );
360              
361             return unless ref $object eq 'Git::PurePerl::Object::Tree';
362              
363             return $self->_find_file( $object, file(@path,$path->basename) );
364             }
365              
366             return first { $_->filename eq $path->basename } $tree->directory_entries;
367             }
368              
369             sub history {
370             my ( $self, $path ) = @_;
371              
372             my $head = $self->branch_head
373             or return;
374              
375             my @q = ( $self->git->get_object($head) );
376              
377             my @commits;
378             while ( @q ) {
379             push @q, $q[0]->parents;
380             unshift @commits, shift @q;
381             }
382              
383             my @history_commits;
384             my %sha1_seen;
385              
386             for my $c ( @commits ) {
387             my $file = $self->_find_file( $c->tree, file($path) ) or next;
388             push @history_commits, $c unless $sha1_seen{ $file->object->sha1 }++;
389             }
390              
391             return map {
392             GitStore::Revision->new(
393             path => $path,
394             gitstore => $self,
395             sha1 => $_->sha1,
396             )
397             } @history_commits;
398              
399             }
400              
401             sub list {
402             my( $self, $regex ) = @_;
403              
404             croak "'$regex' is not a a regex"
405             if $regex and ref $regex ne 'Regexp';
406              
407             my $head = $self->branch_head or return;
408              
409             my $commit = $self->git->get_object($head);
410             my $tree = $commit->tree;
411              
412             my $root = $self->_expand_directories( $tree );
413              
414             my @dirs = ( [ '', $root ] );
415             my @entries;
416              
417             while( my $dir = shift @dirs ) {
418             my $path = $dir->[0];
419             $dir = $dir->[1];
420             push @dirs, [ "$path/$_" => $dir->{DIRS}{$_} ]
421             for sort keys %{$dir->{DIRS}};
422              
423             for ( sort keys %{$dir->{FILES}} ) {
424             my $f = "$path/$_";
425             $f =~ s#^/##; # TODO improve this
426             next if $regex and $f !~ $regex;
427             push @entries, $f;
428             }
429             }
430              
431             return @entries;
432             }
433              
434              
435             no Moose;
436             __PACKAGE__->meta->make_immutable;
437              
438             1;
439              
440             __END__
441              
442             =pod
443              
444             =head1 NAME
445              
446             GitStore - Git as versioned data store in Perl
447              
448             =head1 VERSION
449              
450             version 0.15
451              
452             =head1 SYNOPSIS
453              
454             use GitStore;
455              
456             my $gs = GitStore->new('/path/to/repo');
457             $gs->set( 'users/obj.txt', $obj );
458             $gs->set( ['config', 'wiki.txt'], { hash_ref => 1 } );
459             $gs->commit();
460             $gs->set( 'yyy/xxx.log', 'Log me' );
461             $gs->discard();
462            
463             # later or in another pl
464             my $val = $gs->get( 'user/obj.txt' ); # $val is the same as $obj
465             my $val = $gs->get( 'config/wiki.txt' ); # $val is { hashref => 1 } );
466             my $val = $gs->get( ['yyy', 'xxx.log' ] ); # $val is undef since discard
467              
468             =head1 DESCRIPTION
469              
470             It is inspired by the Python and Ruby binding. check SEE ALSO
471              
472             =head1 METHODS
473              
474             =head2 new
475              
476             GitStore->new('/path/to/repo');
477             GitStore->new( repo => '/path/to/repo', branch => 'mybranch' );
478             GitStore->new( repo => '/path/to/repo', author => 'Someone Unknown <unknown\@what.com>' );
479              
480             =over 4
481              
482             =item repo
483              
484             your git directory or work directory (I<GitStore> will assume it's a work
485             directory if it doesn't end with C<.git>).
486              
487             =item branch
488              
489             your branch name, default is 'master'
490              
491             =item author
492              
493             It is used in the commit info
494              
495             =item serializer
496              
497             Can be used to define a serializing function that will be used if the value to
498             save is a reference. When invoked, the function will be passed a reference to
499             the store object, the path under which the value will be saved, and the value
500             itself. For example, one could do different serialization via:
501              
502             my $store = GitStore->new(
503             repo => '/path/to/repo',
504             serializer => sub {
505             my( $store, $path, $value ) = @_;
506              
507             if ( $path =~ m#^json# ) {
508             return encode_json($value);
509             }
510             else {
511             # defaults to YAML
512             return YAML::Dump($value);
513             }
514             },
515             );
516              
517             The default serializer uses L<Storable/nfreeze>.
518              
519             =item deserializer
520              
521             Called when a value is picked from the store to be (potentially) deserialized.
522             Just like the serializer function, it is passed three arguments: the store
523             object, the path of the value to deserialize and the value itself. To revisit
524             the example for C<serializer>, the full serializer/deserializer dance would
525             be:
526              
527             my $store = GitStore->new(
528             repo => '/path/to/repo',
529             serializer => sub {
530             my( $store, $path, $value ) = @_;
531              
532             return $path =~ m#^json#
533             ? encode_json($value)
534             : YAML::Dump($value)
535             ;
536             },
537             deserializer => sub {
538             my( $store, $path, $value ) = @_;
539            
540             return $path =~ #^json#
541             ?decode_json($value)
542             : YAML::Load($value)
543             ;
544             },
545             );
546              
547             The default deserializer will try to deserialize the value
548             retrieved from the store via L<Storable/thaw> and, if this fails,
549             return the value verbatim.
550              
551             =item autocommit
552              
553             If set to C<true>, any call to C<set()> or C<delete()> will automatically call an
554             implicit C<commit()>. Defaults to C<false>.
555              
556             =back
557              
558             =head2 set($path, $val)
559              
560             $gs->set( 'yyy/xxx.log', 'Log me' );
561             $gs->set( ['config', 'wiki.txt'], { hash_ref => 1 } );
562             $gs->set( 'users/obj.txt', $obj );
563              
564             Store $val as a $path file in Git
565              
566             $path can be String or ArrayRef. Any leading slashes ('/') in the path
567             will be stripped, as to make it a valid Git path. The same
568             grooming is done for the C<get()> and C<delete()> methods.
569              
570             $val can be String or Ref[HashRef|ArrayRef|Ref[Ref]] or blessed Object
571              
572             =head2 get($path)
573              
574             $gs->get( 'user/obj.txt' );
575             $gs->get( ['yyy', 'xxx.log' ] );
576              
577             Get $val from the $path file
578              
579             $path can be String or ArrayRef
580              
581             =head2 get_revision( $path )
582              
583             Like C<get()>, but returns the L<GitStore::Revision> object corresponding to
584             the latest Git revision on the monitored branch for which C<$path> changed.
585              
586             =head2 delete($path)
587              
588             =head2 remove($path)
589              
590             remove $path from Git store
591              
592             =head2 commit
593              
594             $gs->commit();
595             $gs->commit('Your Comments Here');
596              
597             commit the B<set> changes into Git
598              
599             =head2 discard
600              
601             $gs->discard();
602              
603             discard the B<set> changes
604              
605             =head2 history($path)
606              
607             Returns a list of L<GitStore::Revision> objects representing the changes
608             brought to the I<$path>. The changes are returned in ascending commit order.
609              
610             =head2 list($regex)
611              
612             @entries = $gs->list( qr/\.txt$/ );
613              
614             Returns a list of all entries in the repository, possibly filtered by the
615             optional I<$regex>.
616              
617             =head1 FAQ
618              
619             =head2 why the files are B<not> there?
620              
621             run
622              
623             git checkout
624              
625             =head2 any example?
626              
627             # if you just need a local repo, that's all you need.
628             mkdir sandbox
629             cd sandbox
630             git init
631             # use GitStore->new('/path/to/this/sandbox')
632             # set something
633             git checkout
634            
635             # follows are for remote git url
636             git remote add origin git@github.com:fayland/sandbox2.git
637             git push origin master
638             # do more GitStore->new('/path/to/this/sandbox') later
639             git checkout
640             git pull origin master
641             git push
642              
643             =head1 KNOWN BUGS
644              
645             If all files are deleted from the repository, a 'dummy' file
646             will be created to keep Git happy.
647              
648             =head1 SEE ALSO
649              
650             =over 4
651              
652             =item Article
653              
654             L<http://www.newartisans.com/2008/05/using-git-as-a-versioned-data-store-in-python.html>
655              
656             =item Python binding
657              
658             L<http://github.com/jwiegley/git-issues/tree/master>
659              
660             =item Ruby binding
661              
662             L<http://github.com/georgi/git_store/tree/master>
663              
664             =back
665              
666             =head1 Git URL
667              
668             L<http://github.com/fayland/perl-git-store/tree/master>
669              
670             =head1 AUTHORS
671              
672             =over 4
673              
674             =item *
675              
676             Fayland Lam <fayland@gmail.com>
677              
678             =item *
679              
680             Yanick Champoux <yanick@cpan.org>
681              
682             =back
683              
684             =head1 COPYRIGHT AND LICENSE
685              
686             This software is copyright (c) 2013 by Fayland Lam <fayland@gmail.com>.
687              
688             This is free software; you can redistribute it and/or modify it under
689             the same terms as the Perl 5 programming language system itself.
690              
691             =cut