package Git::SVN::Fetcher; use vars qw/@ISA $_ignore_regex $_include_regex $_preserve_empty_dirs $_placeholder_filename @deleted_gpath %added_placeholder $repo_id/; use strict; use warnings; use SVN::Delta; use Carp qw/croak/; use File::Basename qw/dirname/; use Git qw/command command_oneline command_noisy command_output_pipe command_input_pipe command_close_pipe command_bidi_pipe command_close_bidi_pipe get_record/; BEGIN { @ISA = qw(SVN::Delta::Editor); } # file baton members: path, mode_a, mode_b, pool, fh, blob, base sub new { my ($class, $git_svn, $switch_path) = @_; my $self = SVN::Delta::Editor->new; bless $self, $class; if (exists $git_svn->{last_commit}) { $self->{c} = $git_svn->{last_commit}; $self->{empty_symlinks} = _mark_empty_symlinks($git_svn, $switch_path); } # some options are read globally, but can be overridden locally # per [svn-remote "..."] section. Command-line options will *NOT* # override options set in an [svn-remote "..."] section $repo_id = $git_svn->{repo_id}; my $k = "svn-remote.$repo_id.ignore-paths"; my $v = eval { command_oneline('config', '--get', $k) }; $self->{ignore_regex} = $v; $k = "svn-remote.$repo_id.include-paths"; $v = eval { command_oneline('config', '--get', $k) }; $self->{include_regex} = $v; $k = "svn-remote.$repo_id.preserve-empty-dirs"; $v = eval { command_oneline('config', '--get', '--bool', $k) }; if ($v && $v eq 'true') { $_preserve_empty_dirs = 1; $k = "svn-remote.$repo_id.placeholder-filename"; $v = eval { command_oneline('config', '--get', $k) }; $_placeholder_filename = $v; } # Load the list of placeholder files added during previous invocations. $k = "svn-remote.$repo_id.added-placeholder"; $v = eval { command_oneline('config', '--get-all', $k) }; if ($_preserve_empty_dirs && $v) { # command() prints errors to stderr, so we only call it if # command_oneline() succeeded. my @v = command('config', '--get-all', $k); $added_placeholder{ dirname($_) } = $_ foreach @v; } $self->{empty} = {}; $self->{dir_prop} = {}; $self->{file_prop} = {}; $self->{absent_dir} = {}; $self->{absent_file} = {}; require Git::IndexInfo; $self->{gii} = $git_svn->tmp_index_do(sub { Git::IndexInfo->new }); $self->{pathnameencoding} = Git::config('svn.pathnameencoding'); $self; } # this uses the Ra object, so it must be called before do_{switch,update}, # not inside them (when the Git::SVN::Fetcher object is passed) to # do_{switch,update} sub _mark_empty_symlinks { my ($git_svn, $switch_path) = @_; my $bool = Git::config_bool('svn.brokenSymlinkWorkaround'); return {} if (!defined($bool)) || (defined($bool) && ! $bool); my %ret; my ($rev, $cmt) = $git_svn->last_rev_commit; return {} unless ($rev && $cmt); # allow the warning to be printed for each revision we fetch to # ensure the user sees it. The user can also disable the workaround # on the repository even while git svn is running and the next # revision fetched will skip this expensive function. my $printed_warning; chomp(my $empty_blob = `git hash-object -t blob --stdin < /dev/null`); my ($ls, $ctx) = command_output_pipe(qw/ls-tree -r -z/, $cmt); my $pfx = defined($switch_path) ? $switch_path : $git_svn->path; $pfx .= '/' if length($pfx); while (defined($_ = get_record($ls, "\0"))) { s/\A100644 blob $empty_blob\t//o or next; unless ($printed_warning) { print STDERR "Scanning for empty symlinks, ", "this may take a while if you have ", "many empty files\n", "You may disable this with `", "git config svn.brokenSymlinkWorkaround ", "false'.\n", "This may be done in a different ", "terminal without restarting ", "git svn\n"; $printed_warning = 1; } my $path = $_; my (undef, $props) = $git_svn->ra->get_file($pfx.$path, $rev, undef); if ($props->{'svn:special'}) { $ret{$path} = 1; } } command_close_pipe($ls, $ctx); \%ret; } # returns true if a given path is inside a ".git" directory sub in_dot_git { $_[0] =~ m{(?:^|/)\.git(?:/|$)}; } # return value: 0 -- don't ignore, 1 -- ignore # This will also check whether the path is explicitly included sub is_path_ignored { my ($self, $path) = @_; return 1 if in_dot_git($path); return 1 if defined($self->{ignore_regex}) && $path =~ m!$self->{ignore_regex}!; return 0 if defined($self->{include_regex}) && $path =~ m!$self->{include_regex}!; return 0 if defined($_include_regex) && $path =~ m!$_include_regex!; return 1 if defined($self->{include_regex}); return 1 if defined($_include_regex); return 0 unless defined($_ignore_regex); return 1 if $path =~ m!$_ignore_regex!o; return 0; } sub set_path_strip { my ($self, $path) = @_; $self->{path_strip} = qr/^\Q$path\E(\/|$)/ if length $path; } sub open_root { { path => '' }; } sub open_directory { my ($self, $path, $pb, $rev) = @_; { path => $path }; } sub git_path { my ($self, $path) = @_; if (my $enc = $self->{pathnameencoding}) { require Encode; Encode::from_to($path, 'UTF-8', $enc); } if ($self->{path_strip}) { $path =~ s!$self->{path_strip}!! or die "Failed to strip path '$path' ($self->{path_strip})\n"; } $path; } sub delete_entry { my ($self, $path, $rev, $pb) = @_; return undef if $self->is_path_ignored($path); my $gpath = $self->git_path($path); return undef if ($gpath eq ''); # remove entire directories. my ($tree) = (command('ls-tree', '-z', $self->{c}, "./$gpath") =~ /\A040000 tree ([a-f\d]{40})\t\Q$gpath\E\0/); if ($tree) { my ($ls, $ctx) = command_output_pipe(qw/ls-tree -r --name-only -z/, $tree); while (defined($_ = get_record($ls, "\0"))) { my $rmpath = "$gpath/$_"; $self->{gii}->remove($rmpath); print "\tD\t$rmpath\n" unless $::_q; } print "\tD\t$gpath/\n" unless $::_q; command_close_pipe($ls, $ctx); } else { $self->{gii}->remove($gpath); print "\tD\t$gpath\n" unless $::_q; } # Don't add to @deleted_gpath if we're deleting a placeholder file. push @deleted_gpath, $gpath unless $added_placeholder{dirname($path)}; $self->{empty}->{$path} = 0; undef; } sub open_file { my ($self, $path, $pb, $rev) = @_; my ($mode, $blob); goto out if $self->is_path_ignored($path); my $gpath = $self->git_path($path); ($mode, $blob) = (command('ls-tree', '-z', $self->{c}, "./$gpath") =~ /\A(\d{6}) blob ([a-f\d]{40})\t\Q$gpath\E\0/); unless (defined $mode && defined $blob) { die "$path was not found in commit $self->{c} (r$rev)\n"; } if ($mode eq '100644' && $self->{empty_symlinks}->{$path}) { $mode = '120000'; } out: { path => $path, mode_a => $mode, mode_b => $mode, blob => $blob, pool => SVN::Pool->new, action => 'M' }; } sub add_file { my ($self, $path, $pb, $cp_path, $cp_rev) = @_; my $mode; if (!$self->is_path_ignored($path)) { my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#); delete $self->{empty}->{$dir}; $mode = '100644'; if ($added_placeholder{$dir}) { # Remove our placeholder file, if we created one. delete_entry($self, $added_placeholder{$dir}) unless $path eq $added_placeholder{$dir}; delete $added_placeholder{$dir} } } { path => $path, mode_a => $mode, mode_b => $mode, pool => SVN::Pool->new, action => 'A' }; } sub add_directory { my ($self, $path, $cp_path, $cp_rev) = @_; goto out if $self->is_path_ignored($path); my $gpath = $self->git_path($path); if ($gpath eq '') { my ($ls, $ctx) = command_output_pipe(qw/ls-tree -r --name-only -z/, $self->{c}); while (defined($_ = get_record($ls, "\0"))) { $self->{gii}->remove($_); print "\tD\t$_\n" unless $::_q; push @deleted_gpath, $gpath; } command_close_pipe($ls, $ctx); $self->{empty}->{$path} = 0; } my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#); delete $self->{empty}->{$dir}; $self->{empty}->{$path} = 1; if ($added_placeholder{$dir}) { # Remove our placeholder file, if we created one. delete_entry($self, $added_placeholder{$dir}); delete $added_placeholder{$dir} } out: { path => $path }; } sub change_dir_prop { my ($self, $db, $prop, $value) = @_; return undef if $self->is_path_ignored($db->{path}); $self->{dir_prop}->{$db->{path}} ||= {}; $self->{dir_prop}->{$db->{path}}->{$prop} = $value; undef; } sub absent_directory { my ($self, $path, $pb) = @_; return undef if $self->is_path_ignored($path); $self->{absent_dir}->{$pb->{path}} ||= []; push @{$self->{absent_dir}->{$pb->{path}}}, $path; undef; } sub absent_file { my ($self, $path, $pb) = @_; return undef if $self->is_path_ignored($path); $self->{absent_file}->{$pb->{path}} ||= []; push @{$self->{absent_file}->{$pb->{path}}}, $path; undef; } sub change_file_prop { my ($self, $fb, $prop, $value) = @_; return undef if $self->is_path_ignored($fb->{path}); if ($prop eq 'svn:executable') { if ($fb->{mode_b} != 120000) { $fb->{mode_b} = defined $value ? 100755 : 100644; } } elsif ($prop eq 'svn:special') { $fb->{mode_b} = defined $value ? 120000 : 100644; } else { $self->{file_prop}->{$fb->{path}} ||= {}; $self->{file_prop}->{$fb->{path}}->{$prop} = $value; } undef; } sub apply_textdelta { my ($self, $fb, $exp) = @_; return undef if $self->is_path_ignored($fb->{path}); my $suffix = 0; ++$suffix while $::_repository->temp_is_locked("svn_delta_${$}_$suffix"); my $fh = $::_repository->temp_acquire("svn_delta_${$}_$suffix"); # $fh gets auto-closed() by SVN::TxDelta::apply(), # (but $base does not,) so dup() it for reading in close_file open my $dup, '<&', $fh or croak $!; my $base = $::_repository->temp_acquire("git_blob_${$}_$suffix"); # close_file may call temp_acquire on 'svn_hash', but because of the # call chain, if the temp_acquire call from close_file ends up being the # call that first creates the 'svn_hash' temp file, then the FileHandle # that's created as a result will end up in an SVN::Pool that we clear # in SVN::Ra::gs_fetch_loop_common. Avoid that by making sure the # 'svn_hash' FileHandle is already created before close_file is called. my $tmp_fh = $::_repository->temp_acquire('svn_hash'); $::_repository->temp_release($tmp_fh, 1); if ($fb->{blob}) { my ($base_is_link, $size); if ($fb->{mode_a} eq '120000' && ! $self->{empty_symlinks}->{$fb->{path}}) { print $base 'link ' or die "print $!\n"; $base_is_link = 1; } retry: $size = $::_repository->cat_blob($fb->{blob}, $base); die "Failed to read object $fb->{blob}" if ($size < 0); if (defined $exp) { seek $base, 0, 0 or croak $!; my $got = ::md5sum($base); if ($got ne $exp) { my $err = "Checksum mismatch: ". "$fb->{path} $fb->{blob}\n" . "expected: $exp\n" . " got: $got\n"; if ($base_is_link) { warn $err, "Retrying... (possibly ", "a bad symlink from SVN)\n"; $::_repository->temp_reset($base); $base_is_link = 0; goto retry; } die $err; } } } seek $base, 0, 0 or croak $!; $fb->{fh} = $fh; $fb->{base} = $base; [ SVN::TxDelta::apply($base, $dup, undef, $fb->{path}, $fb->{pool}) ]; } sub close_file { my ($self, $fb, $exp) = @_; return undef if $self->is_path_ignored($fb->{path}); my $hash; my $path = $self->git_path($fb->{path}); if (my $fh = $fb->{fh}) { if (defined $exp) { seek($fh, 0, 0) or croak $!; my $got = ::md5sum($fh); if ($got ne $exp) { die "Checksum mismatch: $path\n", "expected: $exp\n got: $got\n"; } } if ($fb->{mode_b} == 120000) { sysseek($fh, 0, 0) or croak $!; my $rd = sysread($fh, my $buf, 5); if (!defined $rd) { croak "sysread: $!\n"; } elsif ($rd == 0) { warn "$path has mode 120000", " but it points to nothing\n", "converting to an empty file with mode", " 100644\n"; $fb->{mode_b} = '100644'; } elsif ($buf ne 'link ') { warn "$path has mode 120000", " but is not a link\n"; } else { my $tmp_fh = $::_repository->temp_acquire( 'svn_hash'); my $res; while ($res = sysread($fh, my $str, 1024)) { my $out = syswrite($tmp_fh, $str, $res); defined($out) && $out == $res or croak("write ", Git::temp_path($tmp_fh), ": $!\n"); } defined $res or croak $!; ($fh, $tmp_fh) = ($tmp_fh, $fh); Git::temp_release($tmp_fh, 1); } } $hash = $::_repository->hash_and_insert_object( Git::temp_path($fh)); $hash =~ /^[a-f\d]{40}$/ or die "not a sha1: $hash\n"; Git::temp_release($fb->{base}, 1); Git::temp_release($fh, 1); } else { $hash = $fb->{blob} or die "no blob information\n"; } $fb->{pool}->clear; $self->{gii}->update($fb->{mode_b}, $hash, $path) or croak $!; print "\t$fb->{action}\t$path\n" if $fb->{action} && ! $::_q; undef; } sub abort_edit { my $self = shift; $self->{nr} = $self->{gii}->{nr}; delete $self->{gii}; $self->SUPER::abort_edit(@_); } sub close_edit { my $self = shift; if ($_preserve_empty_dirs) { my @empty_dirs; # Any entry flagged as empty that also has an associated # dir_prop represents a newly created empty directory. foreach my $i (keys %{$self->{empty}}) { push @empty_dirs, $i if exists $self->{dir_prop}->{$i}; } # Search for directories that have become empty due subsequent # file deletes. push @empty_dirs, $self->find_empty_directories(); # Finally, add a placeholder file to each empty directory. $self->add_placeholder_file($_) foreach (@empty_dirs); $self->stash_placeholder_list(); } $self->{git_commit_ok} = 1; $self->{nr} = $self->{gii}->{nr}; delete $self->{gii}; $self->SUPER::close_edit(@_); } sub find_empty_directories { my ($self) = @_; my @empty_dirs; my %dirs = map { dirname($_) => 1 } @deleted_gpath; foreach my $dir (sort keys %dirs) { next if $dir eq "."; # If there have been any additions to this directory, there is # no reason to check if it is empty. my $skip_added = 0; foreach my $t (qw/dir_prop file_prop/) { foreach my $path (keys %{ $self->{$t} }) { if (exists $self->{$t}->{dirname($path)}) { $skip_added = 1; last; } } last if $skip_added; } next if $skip_added; # Use `git ls-tree` to get the filenames of this directory # that existed prior to this particular commit. my $ls = command('ls-tree', '-z', '--name-only', $self->{c}, "$dir/"); my %files = map { $_ => 1 } split(/\0/, $ls); # Remove the filenames that were deleted during this commit. delete $files{$_} foreach (@deleted_gpath); # Report the directory if there are no filenames left. push @empty_dirs, $dir unless (scalar %files); } @empty_dirs; } sub add_placeholder_file { my ($self, $dir) = @_; my $path = "$dir/$_placeholder_filename"; my $gpath = $self->git_path($path); my $fh = $::_repository->temp_acquire($gpath); my $hash = $::_repository->hash_and_insert_object(Git::temp_path($fh)); Git::temp_release($fh, 1); $self->{gii}->update('100644', $hash, $gpath) or croak $!; # The directory should no longer be considered empty. delete $self->{empty}->{$dir} if exists $self->{empty}->{$dir}; # Keep track of any placeholder files we create. $added_placeholder{$dir} = $path; } sub stash_placeholder_list { my ($self) = @_; my $k = "svn-remote.$repo_id.added-placeholder"; my $v = eval { command_oneline('config', '--get-all', $k) }; command_noisy('config', '--unset-all', $k) if $v; foreach (values %added_placeholder) { command_noisy('config', '--add', $k, $_); } } 1; __END__ =head1 NAME Git::SVN::Fetcher - tree delta consumer for "git svn fetch" =head1 SYNOPSIS use SVN::Core; use SVN::Ra; use Git::SVN; use Git::SVN::Fetcher; use Git; my $gs = Git::SVN->find_by_url($url); my $ra = SVN::Ra->new(url => $url); my $editor = Git::SVN::Fetcher->new($gs); my $reporter = $ra->do_update($SVN::Core::INVALID_REVNUM, '', 1, $editor); $reporter->set_path('', $old_rev, 0); $reporter->finish_report; my $tree = $gs->tmp_index_do(sub { command_oneline('write-tree') }); foreach my $path (keys %{$editor->{dir_prop}) { my $props = $editor->{dir_prop}{$path}; foreach my $prop (keys %$props) { print "property $prop at $path changed to $props->{$prop}\n"; } } foreach my $path (keys %{$editor->{empty}) { my $action = $editor->{empty}{$path} ? 'added' : 'removed'; print "empty directory $path $action\n"; } foreach my $path (keys %{$editor->{file_prop}) { ... } foreach my $parent (keys %{$editor->{absent_dir}}) { my @children = @{$editor->{abstent_dir}{$parent}}; print "cannot fetch directory $parent/$_: not authorized?\n" foreach @children; } foreach my $parent (keys %{$editor->{absent_file}) { ... } =head1 DESCRIPTION This is a subclass of C<SVN::Delta::Editor>, which means it implements callbacks to act as a consumer of Subversion tree deltas. This particular implementation of those callbacks is meant to store information about the resulting content which B<git svn fetch> could use to populate new commits and new entries for F<unhandled.log>. More specifically: =over =item * Additions, removals, and modifications of files are propagated to git-svn's index file F<$GIT_DIR/svn/$refname/index> using B<git update-index>. =item * Changes in Subversion path properties are recorded in the C<dir_prop> and C<file_prop> fields (which are hashes). =item * Addition and removal of empty directories are indicated by entries with value 1 and 0 respectively in the C<empty> hash. =item * Paths that are present but cannot be conveyed (presumably due to permissions) are recorded in the C<absent_file> and C<absent_dirs> hashes. For each key, the corresponding value is a list of paths under that directory that were present but could not be conveyed. =back The interface is unstable. Do not use this module unless you are developing git-svn. =head1 DEPENDENCIES L<SVN::Delta> from the Subversion perl bindings, the core L<Carp> and L<File::Basename> modules, and git's L<Git> helper module. C<Git::SVN::Fetcher> has not been tested using callers other than B<git-svn> itself. =head1 SEE ALSO L<SVN::Delta>, L<Git::SVN::Editor>. =head1 INCOMPATIBILITIES None reported. =head1 BUGS None.