diff options
Diffstat (limited to 'git-svn.perl')
-rwxr-xr-x | git-svn.perl | 524 |
1 files changed, 512 insertions, 12 deletions
diff --git a/git-svn.perl b/git-svn.perl index 89f83fd27..b608bbf95 100755 --- a/git-svn.perl +++ b/git-svn.perl @@ -87,13 +87,15 @@ my ($_stdin, $_help, $_edit, $_version, $_fetch_all, $_no_rebase, $_fetch_parent, $_merge, $_strategy, $_dry_run, $_local, $_prefix, $_no_checkout, $_url, $_verbose, - $_git_format, $_commit_url, $_tag, $_merge_info); + $_git_format, $_commit_url, $_tag, $_merge_info, $_interactive); $Git::SVN::_follow_parent = 1; +$SVN::Git::Fetcher::_placeholder_filename = ".gitignore"; $_q ||= 0; my %remote_opts = ( 'username=s' => \$Git::SVN::Prompt::_username, 'config-dir=s' => \$Git::SVN::Ra::config_dir, 'no-auth-cache' => \$Git::SVN::Prompt::_no_auth_cache, - 'ignore-paths=s' => \$SVN::Git::Fetcher::_ignore_regex ); + 'ignore-paths=s' => \$SVN::Git::Fetcher::_ignore_regex, + 'ignore-refs=s' => \$Git::SVN::Ra::_ignore_refs_regex ); my %fc_opts = ( 'follow-parent|follow!' => \$Git::SVN::_follow_parent, 'authors-file|A=s' => \$_authors, 'authors-prog=s' => \$_authors_prog, @@ -139,6 +141,10 @@ my %cmd = ( %fc_opts } ], clone => [ \&cmd_clone, "Initialize and fetch revisions", { 'revision|r=s' => \$_revision, + 'preserve-empty-dirs' => + \$SVN::Git::Fetcher::_preserve_empty_dirs, + 'placeholder-filename=s' => + \$SVN::Git::Fetcher::_placeholder_filename, %fc_opts, %init_opts } ], init => [ \&cmd_init, "Initialize a repo for tracking" . " (requires URL argument)", @@ -158,6 +164,7 @@ my %cmd = ( 'revision|r=i' => \$_revision, 'no-rebase' => \$_no_rebase, 'mergeinfo=s' => \$_merge_info, + 'interactive|i' => \$_interactive, %cmt_opts, %fc_opts } ], branch => [ \&cmd_branch, 'Create a branch in the SVN repository', @@ -251,6 +258,27 @@ my %cmd = ( {} ], ); +use Term::ReadLine; +package FakeTerm; +sub new { + my ($class, $reason) = @_; + return bless \$reason, shift; +} +sub readline { + my $self = shift; + die "Cannot use readline on FakeTerm: $$self"; +} +package main; + +my $term = eval { + $ENV{"GIT_SVN_NOTTY"} + ? new Term::ReadLine 'git-svn', \*STDIN, \*STDOUT + : new Term::ReadLine 'git-svn'; +}; +if ($@) { + $term = new FakeTerm "$@: going non-interactive"; +} + my $cmd; for (my $i = 0; $i < @ARGV; $i++) { if (defined $cmd{$ARGV[$i]}) { @@ -294,7 +322,7 @@ read_git_config(\%opts); if ($cmd && ($cmd eq 'log' || $cmd eq 'blame')) { Getopt::Long::Configure('pass_through'); } -my $rv = GetOptions(%opts, 'help|H|h' => \$_help, 'version|V' => \$_version, +my $rv = GetOptions(%opts, 'h|H' => \$_help, 'version|V' => \$_version, 'minimize-connections' => \$Git::SVN::Migration::_minimize, 'id|i=s' => \$Git::SVN::default_ref_id, 'svn-remote|remote|R=s' => sub { @@ -361,6 +389,36 @@ sub version { exit 0; } +sub ask { + my ($prompt, %arg) = @_; + my $valid_re = $arg{valid_re}; + my $default = $arg{default}; + my $resp; + my $i = 0; + + if ( !( defined($term->IN) + && defined( fileno($term->IN) ) + && defined( $term->OUT ) + && defined( fileno($term->OUT) ) ) ){ + return defined($default) ? $default : undef; + } + + while ($i++ < 10) { + $resp = $term->readline($prompt); + if (!defined $resp) { # EOF + print "\n"; + return defined $default ? $default : undef; + } + if ($resp eq '' and defined $default) { + return $default; + } + if (!defined $valid_re or $resp =~ /$valid_re/) { + return $resp; + } + } + return undef; +} + sub do_git_init_db { unless (-d $ENV{GIT_DIR}) { my @init_db = ('init'); @@ -383,9 +441,18 @@ sub do_git_init_db { command_noisy('config', "$pfx.$i", $icv{$i}); $set = $i; } - my $ignore_regex = \$SVN::Git::Fetcher::_ignore_regex; - command_noisy('config', "$pfx.ignore-paths", $$ignore_regex) - if defined $$ignore_regex; + my $ignore_paths_regex = \$SVN::Git::Fetcher::_ignore_regex; + command_noisy('config', "$pfx.ignore-paths", $$ignore_paths_regex) + if defined $$ignore_paths_regex; + my $ignore_refs_regex = \$Git::SVN::Ra::_ignore_refs_regex; + command_noisy('config', "$pfx.ignore-refs", $$ignore_refs_regex) + if defined $$ignore_refs_regex; + + if (defined $SVN::Git::Fetcher::_preserve_empty_dirs) { + my $fname = \$SVN::Git::Fetcher::_placeholder_filename; + command_noisy('config', "$pfx.preserve-empty-dirs", 'true'); + command_noisy('config', "$pfx.placeholder-filename", $$fname); + } } sub init_subdir { @@ -497,6 +564,195 @@ sub cmd_set_tree { unlink $gs->{index}; } +sub split_merge_info_range { + my ($range) = @_; + if ($range =~ /(\d+)-(\d+)/) { + return (int($1), int($2)); + } else { + return (int($range), int($range)); + } +} + +sub combine_ranges { + my ($in) = @_; + + my @fnums = (); + my @arr = split(/,/, $in); + for my $element (@arr) { + my ($start, $end) = split_merge_info_range($element); + push @fnums, $start; + } + + my @sorted = @arr [ sort { + $fnums[$a] <=> $fnums[$b] + } 0..$#arr ]; + + my @return = (); + my $last = -1; + my $first = -1; + for my $element (@sorted) { + my ($start, $end) = split_merge_info_range($element); + + if ($last == -1) { + $first = $start; + $last = $end; + next; + } + if ($start <= $last+1) { + if ($end > $last) { + $last = $end; + } + next; + } + if ($first == $last) { + push @return, "$first"; + } else { + push @return, "$first-$last"; + } + $first = $start; + $last = $end; + } + + if ($first != -1) { + if ($first == $last) { + push @return, "$first"; + } else { + push @return, "$first-$last"; + } + } + + return join(',', @return); +} + +sub merge_revs_into_hash { + my ($hash, $minfo) = @_; + my @lines = split(' ', $minfo); + + for my $line (@lines) { + my ($branchpath, $revs) = split(/:/, $line); + + if (exists($hash->{$branchpath})) { + # Merge the two revision sets + my $combined = "$hash->{$branchpath},$revs"; + $hash->{$branchpath} = combine_ranges($combined); + } else { + # Just do range combining for consolidation + $hash->{$branchpath} = combine_ranges($revs); + } + } +} + +sub merge_merge_info { + my ($mergeinfo_one, $mergeinfo_two) = @_; + my %result_hash = (); + + merge_revs_into_hash(\%result_hash, $mergeinfo_one); + merge_revs_into_hash(\%result_hash, $mergeinfo_two); + + my $result = ''; + # Sort below is for consistency's sake + for my $branchname (sort keys(%result_hash)) { + my $revlist = $result_hash{$branchname}; + $result .= "$branchname:$revlist\n" + } + return $result; +} + +sub populate_merge_info { + my ($d, $gs, $uuid, $linear_refs, $rewritten_parent) = @_; + + my %parentshash; + read_commit_parents(\%parentshash, $d); + my @parents = @{$parentshash{$d}}; + if ($#parents > 0) { + # Merge commit + my $all_parents_ok = 1; + my $aggregate_mergeinfo = ''; + my $rooturl = $gs->repos_root; + + if (defined($rewritten_parent)) { + # Replace first parent with newly-rewritten version + shift @parents; + unshift @parents, $rewritten_parent; + } + + foreach my $parent (@parents) { + my ($branchurl, $svnrev, $paruuid) = + cmt_metadata($parent); + + unless (defined($svnrev)) { + # Should have been caught be preflight check + fatal "merge commit $d has ancestor $parent, but that change " + ."does not have git-svn metadata!"; + } + unless ($branchurl =~ /^$rooturl(.*)/) { + fatal "commit $parent git-svn metadata changed mid-run!"; + } + my $branchpath = $1; + + my $ra = Git::SVN::Ra->new($branchurl); + my (undef, undef, $props) = + $ra->get_dir(canonicalize_path("."), $svnrev); + my $par_mergeinfo = $props->{'svn:mergeinfo'}; + unless (defined $par_mergeinfo) { + $par_mergeinfo = ''; + } + # Merge previous mergeinfo values + $aggregate_mergeinfo = + merge_merge_info($aggregate_mergeinfo, + $par_mergeinfo, 0); + + next if $parent eq $parents[0]; # Skip first parent + # Add new changes being placed in tree by merge + my @cmd = (qw/rev-list --reverse/, + $parent, qw/--not/); + foreach my $par (@parents) { + unless ($par eq $parent) { + push @cmd, $par; + } + } + my @revsin = (); + my ($revlist, $ctx) = command_output_pipe(@cmd); + while (<$revlist>) { + my $irev = $_; + chomp $irev; + my (undef, $csvnrev, undef) = + cmt_metadata($irev); + unless (defined $csvnrev) { + # A child is missing SVN annotations... + # this might be OK, or might not be. + warn "W:child $irev is merged into revision " + ."$d but does not have git-svn metadata. " + ."This means git-svn cannot determine the " + ."svn revision numbers to place into the " + ."svn:mergeinfo property. You must ensure " + ."a branch is entirely committed to " + ."SVN before merging it in order for " + ."svn:mergeinfo population to function " + ."properly"; + } + push @revsin, $csvnrev; + } + command_close_pipe($revlist, $ctx); + + last unless $all_parents_ok; + + # We now have a list of all SVN revnos which are + # merged by this particular parent. Integrate them. + next if $#revsin == -1; + my $newmergeinfo = "$branchpath:" . join(',', @revsin); + $aggregate_mergeinfo = + merge_merge_info($aggregate_mergeinfo, + $newmergeinfo, 1); + } + if ($all_parents_ok and $aggregate_mergeinfo) { + return $aggregate_mergeinfo; + } + } + + return undef; +} + sub cmd_dcommit { my $head = shift; command_noisy(qw/update-index --refresh/); @@ -546,8 +802,88 @@ sub cmd_dcommit { "If these changes depend on each other, re-running ", "without --no-rebase may be required." } + + if (defined $_interactive){ + my $ask_default = "y"; + foreach my $d (@$linear_refs){ + my ($fh, $ctx) = command_output_pipe(qw(show --summary), "$d"); + while (<$fh>){ + print $_; + } + command_close_pipe($fh, $ctx); + $_ = ask("Commit this patch to SVN? ([y]es (default)|[n]o|[q]uit|[a]ll): ", + valid_re => qr/^(?:yes|y|no|n|quit|q|all|a)/i, + default => $ask_default); + die "Commit this patch reply required" unless defined $_; + if (/^[nq]/i) { + exit(0); + } elsif (/^a/i) { + last; + } + } + } + my $expect_url = $url; + + my $push_merge_info = eval { + command_oneline(qw/config --get svn.pushmergeinfo/) + }; + if (not defined($push_merge_info) + or $push_merge_info eq "false" + or $push_merge_info eq "no" + or $push_merge_info eq "never") { + $push_merge_info = 0; + } + + unless (defined($_merge_info) || ! $push_merge_info) { + # Preflight check of changes to ensure no issues with mergeinfo + # This includes check for uncommitted-to-SVN parents + # (other than the first parent, which we will handle), + # information from different SVN repos, and paths + # which are not underneath this repository root. + my $rooturl = $gs->repos_root; + foreach my $d (@$linear_refs) { + my %parentshash; + read_commit_parents(\%parentshash, $d); + my @realparents = @{$parentshash{$d}}; + if ($#realparents > 0) { + # Merge commit + shift @realparents; # Remove/ignore first parent + foreach my $parent (@realparents) { + my ($branchurl, $svnrev, $paruuid) = cmt_metadata($parent); + unless (defined $paruuid) { + # A parent is missing SVN annotations... + # abort the whole operation. + fatal "$parent is merged into revision $d, " + ."but does not have git-svn metadata. " + ."Either dcommit the branch or use a " + ."local cherry-pick, FF merge, or rebase " + ."instead of an explicit merge commit."; + } + + unless ($paruuid eq $uuid) { + # Parent has SVN metadata from different repository + fatal "merge parent $parent for change $d has " + ."git-svn uuid $paruuid, while current change " + ."has uuid $uuid!"; + } + + unless ($branchurl =~ /^$rooturl(.*)/) { + # This branch is very strange indeed. + fatal "merge parent $parent for $d is on branch " + ."$branchurl, which is not under the " + ."git-svn root $rooturl!"; + } + } + } + } + } + + my $rewritten_parent; Git::SVN::remove_username($expect_url); + if (defined($_merge_info)) { + $_merge_info =~ tr{ }{\n}; + } while (1) { my $d = shift @$linear_refs or last; unless (defined $last_rev) { @@ -561,6 +897,14 @@ sub cmd_dcommit { print "diff-tree $d~1 $d\n"; } else { my $cmt_rev; + + unless (defined($_merge_info) || ! $push_merge_info) { + $_merge_info = populate_merge_info($d, $gs, + $uuid, + $linear_refs, + $rewritten_parent); + } + my %ed_opts = ( r => $last_rev, log => get_commit_entry($d)->{log}, ra => Git::SVN::Ra->new($url), @@ -603,6 +947,9 @@ sub cmd_dcommit { @finish = qw/reset --mixed/; } command_noisy(@finish, $gs->refname); + + $rewritten_parent = command_oneline(qw/rev-parse HEAD/); + if (@diff) { @refs = (); my ($url_, $rev_, $uuid_, $gs_) = @@ -1849,6 +2196,8 @@ sub read_all_remotes { $r->{$1}->{url} = $2; } elsif (m!^(.+)\.pushurl=\s*(.*)\s*$!) { $r->{$1}->{pushurl} = $2; + } elsif (m!^(.+)\.ignore-refs=\s*(.*)\s*$!) { + $r->{$1}->{ignore_refs_regex} = $2; } elsif (m!^(.+)\.(branches|tags)=$svn_refspec$!) { my ($remote, $t, $local_ref, $remote_ref) = ($1, $2, $3, $4); @@ -1885,6 +2234,16 @@ sub read_all_remotes { } } keys %$r; + foreach my $remote (keys %$r) { + foreach ( grep { defined $_ } + map { $r->{$remote}->{$_} } qw(branches tags) ) { + foreach my $rs ( @$_ ) { + $rs->{ignore_refs_regex} = + $r->{$remote}->{ignore_refs_regex}; + } + } + } + $r; } @@ -3011,7 +3370,7 @@ sub other_gs { my (undef, $max_commit) = $gs->rev_map_max(1); last if (!$max_commit); my ($url) = ::cmt_metadata($max_commit); - last if ($url eq $gs->full_url); + last if ($url eq $gs->metadata_url); $ref_id .= '-'; } print STDERR "Initializing parent: $ref_id\n" unless $::_q > 1; @@ -4080,12 +4439,13 @@ sub _read_password { } package SVN::Git::Fetcher; -use vars qw/@ISA/; +use vars qw/@ISA $_ignore_regex $_preserve_empty_dirs $_placeholder_filename + @deleted_gpath %added_placeholder $repo_id/; use strict; use warnings; use Carp qw/croak/; +use File::Basename qw/dirname/; use IO::File qw//; -use vars qw/$_ignore_regex/; # file baton members: path, mode_a, mode_b, pool, fh, blob, base sub new { @@ -4097,8 +4457,34 @@ sub new { $self->{empty_symlinks} = _mark_empty_symlinks($git_svn, $switch_path); } - $self->{ignore_regex} = eval { command_oneline('config', '--get', - "svn-remote.$git_svn->{repo_id}.ignore-paths") }; + + # 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.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} = {}; @@ -4227,6 +4613,8 @@ sub delete_entry { $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; } @@ -4259,7 +4647,15 @@ sub add_file { 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' }; } @@ -4277,6 +4673,7 @@ sub add_directory { chomp; $self->{gii}->remove($_); print "\tD\t$_\n" unless $::_q; + push @deleted_gpath, $gpath; } command_close_pipe($ls, $ctx); $self->{empty}->{$path} = 0; @@ -4284,6 +4681,13 @@ sub add_directory { 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 }; } @@ -4447,12 +4851,96 @@ sub 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, $_); + } +} + package SVN::Git::Editor; use vars qw/@ISA $_rmdir $_cp_similarity $_find_copies_harder $_rename_limit/; use strict; @@ -4911,7 +5399,7 @@ sub apply_diff { } package Git::SVN::Ra; -use vars qw/@ISA $config_dir $_log_window_size/; +use vars qw/@ISA $config_dir $_ignore_refs_regex $_log_window_size/; use strict; use warnings; my ($ra_invalid, $can_do_switch, %ignored_err, $RA); @@ -5369,6 +5857,17 @@ sub get_dir_globbed { @finalents; } +# return value: 0 -- don't ignore, 1 -- ignore +sub is_ref_ignored { + my ($g, $p) = @_; + my $refname = $g->{ref}->full_path($p); + return 1 if defined($g->{ignore_refs_regex}) && + $refname =~ m!$g->{ignore_refs_regex}!; + return 0 unless defined($_ignore_refs_regex); + return 1 if $refname =~ m!$_ignore_refs_regex!o; + return 0; +} + sub match_globs { my ($self, $exists, $paths, $globs, $r) = @_; @@ -5405,6 +5904,7 @@ sub match_globs { next unless /$g->{path}->{regex}/; my $p = $1; my $pathname = $g->{path}->full_path($p); + next if is_ref_ignored($g, $p); next if $exists->{$pathname}; next if ($self->check_path($pathname, $r) != $SVN::Node::dir); |