perl / Git / SVN / Fetcher.pmon commit Merge branch 'rs/deflate-init-cleanup' into maint (a393c6b)
   1package Git::SVN::Fetcher;
   2use vars qw/@ISA $_ignore_regex $_include_regex $_preserve_empty_dirs
   3            $_placeholder_filename @deleted_gpath %added_placeholder
   4            $repo_id/;
   5use strict;
   6use warnings;
   7use SVN::Delta;
   8use Carp qw/croak/;
   9use File::Basename qw/dirname/;
  10use IO::File qw//;
  11use Git qw/command command_oneline command_noisy command_output_pipe
  12           command_input_pipe command_close_pipe
  13           command_bidi_pipe command_close_bidi_pipe/;
  14BEGIN {
  15        @ISA = qw(SVN::Delta::Editor);
  16}
  17
  18# file baton members: path, mode_a, mode_b, pool, fh, blob, base
  19sub new {
  20        my ($class, $git_svn, $switch_path) = @_;
  21        my $self = SVN::Delta::Editor->new;
  22        bless $self, $class;
  23        if (exists $git_svn->{last_commit}) {
  24                $self->{c} = $git_svn->{last_commit};
  25                $self->{empty_symlinks} =
  26                                  _mark_empty_symlinks($git_svn, $switch_path);
  27        }
  28
  29        # some options are read globally, but can be overridden locally
  30        # per [svn-remote "..."] section.  Command-line options will *NOT*
  31        # override options set in an [svn-remote "..."] section
  32        $repo_id = $git_svn->{repo_id};
  33        my $k = "svn-remote.$repo_id.ignore-paths";
  34        my $v = eval { command_oneline('config', '--get', $k) };
  35        $self->{ignore_regex} = $v;
  36
  37        $k = "svn-remote.$repo_id.include-paths";
  38        $v = eval { command_oneline('config', '--get', $k) };
  39        $self->{include_regex} = $v;
  40
  41        $k = "svn-remote.$repo_id.preserve-empty-dirs";
  42        $v = eval { command_oneline('config', '--get', '--bool', $k) };
  43        if ($v && $v eq 'true') {
  44                $_preserve_empty_dirs = 1;
  45                $k = "svn-remote.$repo_id.placeholder-filename";
  46                $v = eval { command_oneline('config', '--get', $k) };
  47                $_placeholder_filename = $v;
  48        }
  49
  50        # Load the list of placeholder files added during previous invocations.
  51        $k = "svn-remote.$repo_id.added-placeholder";
  52        $v = eval { command_oneline('config', '--get-all', $k) };
  53        if ($_preserve_empty_dirs && $v) {
  54                # command() prints errors to stderr, so we only call it if
  55                # command_oneline() succeeded.
  56                my @v = command('config', '--get-all', $k);
  57                $added_placeholder{ dirname($_) } = $_ foreach @v;
  58        }
  59
  60        $self->{empty} = {};
  61        $self->{dir_prop} = {};
  62        $self->{file_prop} = {};
  63        $self->{absent_dir} = {};
  64        $self->{absent_file} = {};
  65        require Git::IndexInfo;
  66        $self->{gii} = $git_svn->tmp_index_do(sub { Git::IndexInfo->new });
  67        $self->{pathnameencoding} = Git::config('svn.pathnameencoding');
  68        $self;
  69}
  70
  71# this uses the Ra object, so it must be called before do_{switch,update},
  72# not inside them (when the Git::SVN::Fetcher object is passed) to
  73# do_{switch,update}
  74sub _mark_empty_symlinks {
  75        my ($git_svn, $switch_path) = @_;
  76        my $bool = Git::config_bool('svn.brokenSymlinkWorkaround');
  77        return {} if (!defined($bool)) || (defined($bool) && ! $bool);
  78
  79        my %ret;
  80        my ($rev, $cmt) = $git_svn->last_rev_commit;
  81        return {} unless ($rev && $cmt);
  82
  83        # allow the warning to be printed for each revision we fetch to
  84        # ensure the user sees it.  The user can also disable the workaround
  85        # on the repository even while git svn is running and the next
  86        # revision fetched will skip this expensive function.
  87        my $printed_warning;
  88        chomp(my $empty_blob = `git hash-object -t blob --stdin < /dev/null`);
  89        my ($ls, $ctx) = command_output_pipe(qw/ls-tree -r -z/, $cmt);
  90        local $/ = "\0";
  91        my $pfx = defined($switch_path) ? $switch_path : $git_svn->path;
  92        $pfx .= '/' if length($pfx);
  93        while (<$ls>) {
  94                chomp;
  95                s/\A100644 blob $empty_blob\t//o or next;
  96                unless ($printed_warning) {
  97                        print STDERR "Scanning for empty symlinks, ",
  98                                     "this may take a while if you have ",
  99                                     "many empty files\n",
 100                                     "You may disable this with `",
 101                                     "git config svn.brokenSymlinkWorkaround ",
 102                                     "false'.\n",
 103                                     "This may be done in a different ",
 104                                     "terminal without restarting ",
 105                                     "git svn\n";
 106                        $printed_warning = 1;
 107                }
 108                my $path = $_;
 109                my (undef, $props) =
 110                               $git_svn->ra->get_file($pfx.$path, $rev, undef);
 111                if ($props->{'svn:special'}) {
 112                        $ret{$path} = 1;
 113                }
 114        }
 115        command_close_pipe($ls, $ctx);
 116        \%ret;
 117}
 118
 119# returns true if a given path is inside a ".git" directory
 120sub in_dot_git {
 121        $_[0] =~ m{(?:^|/)\.git(?:/|$)};
 122}
 123
 124# return value: 0 -- don't ignore, 1 -- ignore
 125# This will also check whether the path is explicitly included
 126sub is_path_ignored {
 127        my ($self, $path) = @_;
 128        return 1 if in_dot_git($path);
 129        return 1 if defined($self->{ignore_regex}) &&
 130                    $path =~ m!$self->{ignore_regex}!;
 131        return 0 if defined($self->{include_regex}) &&
 132                    $path =~ m!$self->{include_regex}!;
 133        return 0 if defined($_include_regex) &&
 134                    $path =~ m!$_include_regex!;
 135        return 1 if defined($self->{include_regex});
 136        return 1 if defined($_include_regex);
 137        return 0 unless defined($_ignore_regex);
 138        return 1 if $path =~ m!$_ignore_regex!o;
 139        return 0;
 140}
 141
 142sub set_path_strip {
 143        my ($self, $path) = @_;
 144        $self->{path_strip} = qr/^\Q$path\E(\/|$)/ if length $path;
 145}
 146
 147sub open_root {
 148        { path => '' };
 149}
 150
 151sub open_directory {
 152        my ($self, $path, $pb, $rev) = @_;
 153        { path => $path };
 154}
 155
 156sub git_path {
 157        my ($self, $path) = @_;
 158        if (my $enc = $self->{pathnameencoding}) {
 159                require Encode;
 160                Encode::from_to($path, 'UTF-8', $enc);
 161        }
 162        if ($self->{path_strip}) {
 163                $path =~ s!$self->{path_strip}!! or
 164                  die "Failed to strip path '$path' ($self->{path_strip})\n";
 165        }
 166        $path;
 167}
 168
 169sub delete_entry {
 170        my ($self, $path, $rev, $pb) = @_;
 171        return undef if $self->is_path_ignored($path);
 172
 173        my $gpath = $self->git_path($path);
 174        return undef if ($gpath eq '');
 175
 176        # remove entire directories.
 177        my ($tree) = (command('ls-tree', '-z', $self->{c}, "./$gpath")
 178                         =~ /\A040000 tree ([a-f\d]{40})\t\Q$gpath\E\0/);
 179        if ($tree) {
 180                my ($ls, $ctx) = command_output_pipe(qw/ls-tree
 181                                                     -r --name-only -z/,
 182                                                     $tree);
 183                local $/ = "\0";
 184                while (<$ls>) {
 185                        chomp;
 186                        my $rmpath = "$gpath/$_";
 187                        $self->{gii}->remove($rmpath);
 188                        print "\tD\t$rmpath\n" unless $::_q;
 189                }
 190                print "\tD\t$gpath/\n" unless $::_q;
 191                command_close_pipe($ls, $ctx);
 192        } else {
 193                $self->{gii}->remove($gpath);
 194                print "\tD\t$gpath\n" unless $::_q;
 195        }
 196        # Don't add to @deleted_gpath if we're deleting a placeholder file.
 197        push @deleted_gpath, $gpath unless $added_placeholder{dirname($path)};
 198        $self->{empty}->{$path} = 0;
 199        undef;
 200}
 201
 202sub open_file {
 203        my ($self, $path, $pb, $rev) = @_;
 204        my ($mode, $blob);
 205
 206        goto out if $self->is_path_ignored($path);
 207
 208        my $gpath = $self->git_path($path);
 209        ($mode, $blob) = (command('ls-tree', '-z', $self->{c}, "./$gpath")
 210                             =~ /\A(\d{6}) blob ([a-f\d]{40})\t\Q$gpath\E\0/);
 211        unless (defined $mode && defined $blob) {
 212                die "$path was not found in commit $self->{c} (r$rev)\n";
 213        }
 214        if ($mode eq '100644' && $self->{empty_symlinks}->{$path}) {
 215                $mode = '120000';
 216        }
 217out:
 218        { path => $path, mode_a => $mode, mode_b => $mode, blob => $blob,
 219          pool => SVN::Pool->new, action => 'M' };
 220}
 221
 222sub add_file {
 223        my ($self, $path, $pb, $cp_path, $cp_rev) = @_;
 224        my $mode;
 225
 226        if (!$self->is_path_ignored($path)) {
 227                my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#);
 228                delete $self->{empty}->{$dir};
 229                $mode = '100644';
 230
 231                if ($added_placeholder{$dir}) {
 232                        # Remove our placeholder file, if we created one.
 233                        delete_entry($self, $added_placeholder{$dir})
 234                                unless $path eq $added_placeholder{$dir};
 235                        delete $added_placeholder{$dir}
 236                }
 237        }
 238
 239        { path => $path, mode_a => $mode, mode_b => $mode,
 240          pool => SVN::Pool->new, action => 'A' };
 241}
 242
 243sub add_directory {
 244        my ($self, $path, $cp_path, $cp_rev) = @_;
 245        goto out if $self->is_path_ignored($path);
 246        my $gpath = $self->git_path($path);
 247        if ($gpath eq '') {
 248                my ($ls, $ctx) = command_output_pipe(qw/ls-tree
 249                                                     -r --name-only -z/,
 250                                                     $self->{c});
 251                local $/ = "\0";
 252                while (<$ls>) {
 253                        chomp;
 254                        $self->{gii}->remove($_);
 255                        print "\tD\t$_\n" unless $::_q;
 256                        push @deleted_gpath, $gpath;
 257                }
 258                command_close_pipe($ls, $ctx);
 259                $self->{empty}->{$path} = 0;
 260        }
 261        my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#);
 262        delete $self->{empty}->{$dir};
 263        $self->{empty}->{$path} = 1;
 264
 265        if ($added_placeholder{$dir}) {
 266                # Remove our placeholder file, if we created one.
 267                delete_entry($self, $added_placeholder{$dir});
 268                delete $added_placeholder{$dir}
 269        }
 270
 271out:
 272        { path => $path };
 273}
 274
 275sub change_dir_prop {
 276        my ($self, $db, $prop, $value) = @_;
 277        return undef if $self->is_path_ignored($db->{path});
 278        $self->{dir_prop}->{$db->{path}} ||= {};
 279        $self->{dir_prop}->{$db->{path}}->{$prop} = $value;
 280        undef;
 281}
 282
 283sub absent_directory {
 284        my ($self, $path, $pb) = @_;
 285        return undef if $self->is_path_ignored($path);
 286        $self->{absent_dir}->{$pb->{path}} ||= [];
 287        push @{$self->{absent_dir}->{$pb->{path}}}, $path;
 288        undef;
 289}
 290
 291sub absent_file {
 292        my ($self, $path, $pb) = @_;
 293        return undef if $self->is_path_ignored($path);
 294        $self->{absent_file}->{$pb->{path}} ||= [];
 295        push @{$self->{absent_file}->{$pb->{path}}}, $path;
 296        undef;
 297}
 298
 299sub change_file_prop {
 300        my ($self, $fb, $prop, $value) = @_;
 301        return undef if $self->is_path_ignored($fb->{path});
 302        if ($prop eq 'svn:executable') {
 303                if ($fb->{mode_b} != 120000) {
 304                        $fb->{mode_b} = defined $value ? 100755 : 100644;
 305                }
 306        } elsif ($prop eq 'svn:special') {
 307                $fb->{mode_b} = defined $value ? 120000 : 100644;
 308        } else {
 309                $self->{file_prop}->{$fb->{path}} ||= {};
 310                $self->{file_prop}->{$fb->{path}}->{$prop} = $value;
 311        }
 312        undef;
 313}
 314
 315sub apply_textdelta {
 316        my ($self, $fb, $exp) = @_;
 317        return undef if $self->is_path_ignored($fb->{path});
 318        my $suffix = 0;
 319        ++$suffix while $::_repository->temp_is_locked("svn_delta_${$}_$suffix");
 320        my $fh = $::_repository->temp_acquire("svn_delta_${$}_$suffix");
 321        # $fh gets auto-closed() by SVN::TxDelta::apply(),
 322        # (but $base does not,) so dup() it for reading in close_file
 323        open my $dup, '<&', $fh or croak $!;
 324        my $base = $::_repository->temp_acquire("git_blob_${$}_$suffix");
 325        # close_file may call temp_acquire on 'svn_hash', but because of the
 326        # call chain, if the temp_acquire call from close_file ends up being the
 327        # call that first creates the 'svn_hash' temp file, then the FileHandle
 328        # that's created as a result will end up in an SVN::Pool that we clear
 329        # in SVN::Ra::gs_fetch_loop_common.  Avoid that by making sure the
 330        # 'svn_hash' FileHandle is already created before close_file is called.
 331        my $tmp_fh = $::_repository->temp_acquire('svn_hash');
 332        $::_repository->temp_release($tmp_fh, 1);
 333
 334        if ($fb->{blob}) {
 335                my ($base_is_link, $size);
 336
 337                if ($fb->{mode_a} eq '120000' &&
 338                    ! $self->{empty_symlinks}->{$fb->{path}}) {
 339                        print $base 'link ' or die "print $!\n";
 340                        $base_is_link = 1;
 341                }
 342        retry:
 343                $size = $::_repository->cat_blob($fb->{blob}, $base);
 344                die "Failed to read object $fb->{blob}" if ($size < 0);
 345
 346                if (defined $exp) {
 347                        seek $base, 0, 0 or croak $!;
 348                        my $got = ::md5sum($base);
 349                        if ($got ne $exp) {
 350                                my $err = "Checksum mismatch: ".
 351                                       "$fb->{path} $fb->{blob}\n" .
 352                                       "expected: $exp\n" .
 353                                       "     got: $got\n";
 354                                if ($base_is_link) {
 355                                        warn $err,
 356                                             "Retrying... (possibly ",
 357                                             "a bad symlink from SVN)\n";
 358                                        $::_repository->temp_reset($base);
 359                                        $base_is_link = 0;
 360                                        goto retry;
 361                                }
 362                                die $err;
 363                        }
 364                }
 365        }
 366        seek $base, 0, 0 or croak $!;
 367        $fb->{fh} = $fh;
 368        $fb->{base} = $base;
 369        [ SVN::TxDelta::apply($base, $dup, undef, $fb->{path}, $fb->{pool}) ];
 370}
 371
 372sub close_file {
 373        my ($self, $fb, $exp) = @_;
 374        return undef if $self->is_path_ignored($fb->{path});
 375
 376        my $hash;
 377        my $path = $self->git_path($fb->{path});
 378        if (my $fh = $fb->{fh}) {
 379                if (defined $exp) {
 380                        seek($fh, 0, 0) or croak $!;
 381                        my $got = ::md5sum($fh);
 382                        if ($got ne $exp) {
 383                                die "Checksum mismatch: $path\n",
 384                                    "expected: $exp\n    got: $got\n";
 385                        }
 386                }
 387                if ($fb->{mode_b} == 120000) {
 388                        sysseek($fh, 0, 0) or croak $!;
 389                        my $rd = sysread($fh, my $buf, 5);
 390
 391                        if (!defined $rd) {
 392                                croak "sysread: $!\n";
 393                        } elsif ($rd == 0) {
 394                                warn "$path has mode 120000",
 395                                     " but it points to nothing\n",
 396                                     "converting to an empty file with mode",
 397                                     " 100644\n";
 398                                $fb->{mode_b} = '100644';
 399                        } elsif ($buf ne 'link ') {
 400                                warn "$path has mode 120000",
 401                                     " but is not a link\n";
 402                        } else {
 403                                my $tmp_fh = $::_repository->temp_acquire(
 404                                        'svn_hash');
 405                                my $res;
 406                                while ($res = sysread($fh, my $str, 1024)) {
 407                                        my $out = syswrite($tmp_fh, $str, $res);
 408                                        defined($out) && $out == $res
 409                                                or croak("write ",
 410                                                        Git::temp_path($tmp_fh),
 411                                                        ": $!\n");
 412                                }
 413                                defined $res or croak $!;
 414
 415                                ($fh, $tmp_fh) = ($tmp_fh, $fh);
 416                                Git::temp_release($tmp_fh, 1);
 417                        }
 418                }
 419
 420                $hash = $::_repository->hash_and_insert_object(
 421                                Git::temp_path($fh));
 422                $hash =~ /^[a-f\d]{40}$/ or die "not a sha1: $hash\n";
 423
 424                Git::temp_release($fb->{base}, 1);
 425                Git::temp_release($fh, 1);
 426        } else {
 427                $hash = $fb->{blob} or die "no blob information\n";
 428        }
 429        $fb->{pool}->clear;
 430        $self->{gii}->update($fb->{mode_b}, $hash, $path) or croak $!;
 431        print "\t$fb->{action}\t$path\n" if $fb->{action} && ! $::_q;
 432        undef;
 433}
 434
 435sub abort_edit {
 436        my $self = shift;
 437        $self->{nr} = $self->{gii}->{nr};
 438        delete $self->{gii};
 439        $self->SUPER::abort_edit(@_);
 440}
 441
 442sub close_edit {
 443        my $self = shift;
 444
 445        if ($_preserve_empty_dirs) {
 446                my @empty_dirs;
 447
 448                # Any entry flagged as empty that also has an associated
 449                # dir_prop represents a newly created empty directory.
 450                foreach my $i (keys %{$self->{empty}}) {
 451                        push @empty_dirs, $i if exists $self->{dir_prop}->{$i};
 452                }
 453
 454                # Search for directories that have become empty due subsequent
 455                # file deletes.
 456                push @empty_dirs, $self->find_empty_directories();
 457
 458                # Finally, add a placeholder file to each empty directory.
 459                $self->add_placeholder_file($_) foreach (@empty_dirs);
 460
 461                $self->stash_placeholder_list();
 462        }
 463
 464        $self->{git_commit_ok} = 1;
 465        $self->{nr} = $self->{gii}->{nr};
 466        delete $self->{gii};
 467        $self->SUPER::close_edit(@_);
 468}
 469
 470sub find_empty_directories {
 471        my ($self) = @_;
 472        my @empty_dirs;
 473        my %dirs = map { dirname($_) => 1 } @deleted_gpath;
 474
 475        foreach my $dir (sort keys %dirs) {
 476                next if $dir eq ".";
 477
 478                # If there have been any additions to this directory, there is
 479                # no reason to check if it is empty.
 480                my $skip_added = 0;
 481                foreach my $t (qw/dir_prop file_prop/) {
 482                        foreach my $path (keys %{ $self->{$t} }) {
 483                                if (exists $self->{$t}->{dirname($path)}) {
 484                                        $skip_added = 1;
 485                                        last;
 486                                }
 487                        }
 488                        last if $skip_added;
 489                }
 490                next if $skip_added;
 491
 492                # Use `git ls-tree` to get the filenames of this directory
 493                # that existed prior to this particular commit.
 494                my $ls = command('ls-tree', '-z', '--name-only',
 495                                 $self->{c}, "$dir/");
 496                my %files = map { $_ => 1 } split(/\0/, $ls);
 497
 498                # Remove the filenames that were deleted during this commit.
 499                delete $files{$_} foreach (@deleted_gpath);
 500
 501                # Report the directory if there are no filenames left.
 502                push @empty_dirs, $dir unless (scalar %files);
 503        }
 504        @empty_dirs;
 505}
 506
 507sub add_placeholder_file {
 508        my ($self, $dir) = @_;
 509        my $path = "$dir/$_placeholder_filename";
 510        my $gpath = $self->git_path($path);
 511
 512        my $fh = $::_repository->temp_acquire($gpath);
 513        my $hash = $::_repository->hash_and_insert_object(Git::temp_path($fh));
 514        Git::temp_release($fh, 1);
 515        $self->{gii}->update('100644', $hash, $gpath) or croak $!;
 516
 517        # The directory should no longer be considered empty.
 518        delete $self->{empty}->{$dir} if exists $self->{empty}->{$dir};
 519
 520        # Keep track of any placeholder files we create.
 521        $added_placeholder{$dir} = $path;
 522}
 523
 524sub stash_placeholder_list {
 525        my ($self) = @_;
 526        my $k = "svn-remote.$repo_id.added-placeholder";
 527        my $v = eval { command_oneline('config', '--get-all', $k) };
 528        command_noisy('config', '--unset-all', $k) if $v;
 529        foreach (values %added_placeholder) {
 530                command_noisy('config', '--add', $k, $_);
 531        }
 532}
 533
 5341;
 535__END__
 536
 537=head1 NAME
 538
 539Git::SVN::Fetcher - tree delta consumer for "git svn fetch"
 540
 541=head1 SYNOPSIS
 542
 543    use SVN::Core;
 544    use SVN::Ra;
 545    use Git::SVN;
 546    use Git::SVN::Fetcher;
 547    use Git;
 548
 549    my $gs = Git::SVN->find_by_url($url);
 550    my $ra = SVN::Ra->new(url => $url);
 551    my $editor = Git::SVN::Fetcher->new($gs);
 552    my $reporter = $ra->do_update($SVN::Core::INVALID_REVNUM, '',
 553                                  1, $editor);
 554    $reporter->set_path('', $old_rev, 0);
 555    $reporter->finish_report;
 556    my $tree = $gs->tmp_index_do(sub { command_oneline('write-tree') });
 557
 558    foreach my $path (keys %{$editor->{dir_prop}) {
 559        my $props = $editor->{dir_prop}{$path};
 560        foreach my $prop (keys %$props) {
 561            print "property $prop at $path changed to $props->{$prop}\n";
 562        }
 563    }
 564    foreach my $path (keys %{$editor->{empty}) {
 565        my $action = $editor->{empty}{$path} ? 'added' : 'removed';
 566        print "empty directory $path $action\n";
 567    }
 568    foreach my $path (keys %{$editor->{file_prop}) { ... }
 569    foreach my $parent (keys %{$editor->{absent_dir}}) {
 570        my @children = @{$editor->{abstent_dir}{$parent}};
 571        print "cannot fetch directory $parent/$_: not authorized?\n"
 572            foreach @children;
 573    }
 574    foreach my $parent (keys %{$editor->{absent_file}) { ... }
 575
 576=head1 DESCRIPTION
 577
 578This is a subclass of C<SVN::Delta::Editor>, which means it implements
 579callbacks to act as a consumer of Subversion tree deltas.  This
 580particular implementation of those callbacks is meant to store
 581information about the resulting content which B<git svn fetch> could
 582use to populate new commits and new entries for F<unhandled.log>.
 583More specifically:
 584
 585=over
 586
 587=item * Additions, removals, and modifications of files are propagated
 588to git-svn's index file F<$GIT_DIR/svn/$refname/index> using
 589B<git update-index>.
 590
 591=item * Changes in Subversion path properties are recorded in the
 592C<dir_prop> and C<file_prop> fields (which are hashes).
 593
 594=item * Addition and removal of empty directories are indicated by
 595entries with value 1 and 0 respectively in the C<empty> hash.
 596
 597=item * Paths that are present but cannot be conveyed (presumably due
 598to permissions) are recorded in the C<absent_file> and
 599C<absent_dirs> hashes.  For each key, the corresponding value is
 600a list of paths under that directory that were present but
 601could not be conveyed.
 602
 603=back
 604
 605The interface is unstable.  Do not use this module unless you are
 606developing git-svn.
 607
 608=head1 DEPENDENCIES
 609
 610L<SVN::Delta> from the Subversion perl bindings,
 611the core L<Carp>, L<File::Basename>, and L<IO::File> modules,
 612and git's L<Git> helper module.
 613
 614C<Git::SVN::Fetcher> has not been tested using callers other than
 615B<git-svn> itself.
 616
 617=head1 SEE ALSO
 618
 619L<SVN::Delta>,
 620L<Git::SVN::Editor>.
 621
 622=head1 INCOMPATIBILITIES
 623
 624None reported.
 625
 626=head1 BUGS
 627
 628None.