NAME
----
-git-svn - Bidirectional operation between a single Subversion branch and git
+git-svn - Bidirectional operation between a Subversion repository and git
SYNOPSIS
--------
It provides a bidirectional flow of changes between a Subversion and a git
repository.
-'git-svn' can track a single Subversion branch simply by using a
-URL to the branch, follow branches laid out in the Subversion recommended
-method (trunk, branches, tags directories) with the --stdlayout option, or
-follow branches in any layout with the -T/-t/-b options (see options to
-'init' below, and also the 'clone' command).
+'git-svn' can track a standard Subversion repository,
+following the common "trunk/branches/tags" layout, with the --stdlayout option.
+It can also follow branches and tags in any layout with the -T/-t/-b options
+(see options to 'init' below, and also the 'clone' command).
-Once tracking a Subversion branch (with any of the above methods), the git
+Once tracking a Subversion repository (with any of the above methods), the git
repository can be updated from Subversion by the 'fetch' command and
Subversion updated from git by the 'dcommit' command.
--stdlayout;;
These are optional command-line options for init. Each of
these flags can point to a relative repository path
- (--tags=project/tags') or a full url
- (--tags=https://foo.org/project/tags). The option --stdlayout is
+ (--tags=project/tags) or a full url
+ (--tags=https://foo.org/project/tags).
+ You can specify more than one --tags and/or --branches options, in case
+ your Subversion repository places tags or branches under multiple paths.
+ The option --stdlayout is
a shorthand way of setting trunk,tags,branches as the relative paths,
which is the Subversion default. If any of the other options are given
as well, they take precedence.
It is recommended that you run 'git-svn' fetch and rebase (not
pull or merge) your commits against the latest changes in the
SVN repository.
- An optional command-line argument may be specified as an
- alternative to HEAD.
+ An optional revision or branch argument may be specified, and
+ causes 'git-svn' to do all work on that revision/branch
+ instead of HEAD.
This is advantageous over 'set-tree' (below) because it produces
cleaner, more linear history.
+
Create a tag by using the tags_subdir instead of the branches_subdir
specified during git svn init.
+-d;;
+--destination;;
+ If more than one --branches (or --tags) option was given to the 'init'
+ or 'clone' command, you must provide the location of the branch (or
+ tag) you wish to create in the SVN repository. The value of this
+ option must match one of the paths specified by a --branches (or
+ --tags) option. You can see these paths with the commands
++
+ git config --get-all svn-remote.<name>.branches
+ git config --get-all svn-remote.<name>.tags
++
+where <name> is the name of the SVN repository as specified by the -R option to
+'init' (or "svn" by default).
+
'tag'::
Create a tag in the SVN repository. This is a shorthand for
'branch -t'.
The following features from `svn log' are supported:
+
--
---revision=<n>[:<n>];;
+-r/--revision=<n>[:<n>];;
is supported, non-numeric args are not:
HEAD, NEXT, BASE, PREV, etc ...
-v/--verbose;;
Shows the Subversion externals. Use -r/--revision to specify a
specific revision.
+'reset'::
+ Undoes the effects of 'fetch' back to the specified revision.
+ This allows you to re-'fetch' an SVN revision. Normally the
+ contents of an SVN revision should never change and 'reset'
+ should not be necessary. However, if SVN permissions change,
+ or if you alter your --ignore-paths option, a 'fetch' may fail
+ with "not found in commit" (file not previously visible) or
+ "checksum mismatch" (missed a modification). If the problem
+ file cannot be ignored forever (with --ignore-paths) the only
+ way to repair the repo is to use 'reset'.
+
+Only the rev_map and refs/remotes/git-svn are changed. Follow 'reset'
+with a 'fetch' and then 'git-reset' or 'git-rebase' to move local
+branches onto the new tree.
+
+-r/--revision=<n>;;
+ Specify the most recent revision to keep. All later revisions
+ are discarded.
+-p/--parent;;
+ Discard the specified revision as well, keeping the nearest
+ parent instead.
+Example:;;
+Assume you have local changes in "master", but you need to refetch "r2".
+
+------------
+ r1---r2---r3 remotes/git-svn
+ \
+ A---B master
+------------
+
+Fix the ignore-paths or SVN permissions problem that caused "r2" to
+be incomplete in the first place. Then:
+
+[verse]
+git svn reset -r2 -p
+git svn fetch
+
+------------
+ r1---r2'--r3' remotes/git-svn
+ \
+ r2---r3---A---B master
+------------
+
+Then fixup "master" with 'git-rebase'.
+Do NOT use 'git-merge' or your history will not be compatible with a
+future 'dcommit'!
+
+[verse]
+git rebase --onto remotes/git-svn A^ master
+
+------------
+ r1---r2'--r3' remotes/git-svn
+ \
+ A'--B' master
+------------
+
+
--
OPTIONS
you've already pushed to a remote repository for other users, and
dcommit with SVN is analogous to that.
+When using multiple --branches or --tags, 'git-svn' does not automatically
+handle name collisions (for example, if two branches from different paths have
+the same name, or if a branch and a tag have the same name). In these cases,
+use 'init' to set up your git repository then, before your first 'fetch', edit
+the .git/config file so that the branches and tags are associated with
+different name spaces. For example:
+
+ branches = stable/*:refs/remotes/svn/stable/*
+ branches = debug/*:refs/remotes/svn/debug/*
+
BUGS
----
$sha1 = qr/[a-f\d]{40}/;
$sha1_short = qr/[a-f\d]{4,40}/;
my ($_stdin, $_help, $_edit,
- $_message, $_file,
+ $_message, $_file, $_branch_dest,
$_template, $_shared,
$_version, $_fetch_all, $_no_rebase, $_fetch_parent,
$_merge, $_strategy, $_dry_run, $_local,
'localtime' => \$Git::SVN::_localtime,
%remote_opts );
-my ($_trunk, $_tags, $_branches, $_stdlayout);
+my ($_trunk, @_tags, @_branches, $_stdlayout);
my %icv;
my %init_opts = ( 'template=s' => \$_template, 'shared:s' => \$_shared,
- 'trunk|T=s' => \$_trunk, 'tags|t=s' => \$_tags,
- 'branches|b=s' => \$_branches, 'prefix=s' => \$_prefix,
+ 'trunk|T=s' => \$_trunk, 'tags|t=s@' => \@_tags,
+ 'branches|b=s@' => \@_branches, 'prefix=s' => \$_prefix,
'stdlayout|s' => \$_stdlayout,
'minimize-url|m' => \$Git::SVN::_minimize_url,
'no-metadata' => sub { $icv{noMetadata} = 1 },
branch => [ \&cmd_branch,
'Create a branch in the SVN repository',
{ 'message|m=s' => \$_message,
+ 'destination|d=s' => \$_branch_dest,
'dry-run|n' => \$_dry_run,
'tag|t' => \$_tag } ],
tag => [ sub { $_tag = 1; cmd_branch(@_) },
'Create a tag in the SVN repository',
{ 'message|m=s' => \$_message,
+ 'destination|d=s' => \$_branch_dest,
'dry-run|n' => \$_dry_run } ],
'set-tree' => [ \&cmd_set_tree,
"Set an SVN repository to a git tree-ish",
'blame' => [ \&Git::SVN::Log::cmd_blame,
"Show what revision and author last modified each line of a file",
{ 'git-format' => \$_git_format } ],
+ 'reset' => [ \&cmd_reset,
+ "Undo fetches back to the specified SVN revision",
+ { 'revision|r=s' => \$_revision,
+ 'parent|p' => \$_fetch_parent } ],
);
my $cmd;
$cmd = $ARGV[$i];
splice @ARGV, $i, 1;
last;
+ } elsif ($ARGV[$i] eq 'help') {
+ $cmd = $ARGV[$i+1];
+ usage(0);
}
};
sub cmd_clone {
my ($url, $path) = @_;
if (!defined $path &&
- (defined $_trunk || defined $_branches || defined $_tags ||
+ (defined $_trunk || @_branches || @_tags ||
defined $_stdlayout) &&
$url !~ m#^[a-z\+]+://#) {
$path = $url;
sub cmd_init {
if (defined $_stdlayout) {
$_trunk = 'trunk' if (!defined $_trunk);
- $_tags = 'tags' if (!defined $_tags);
- $_branches = 'branches' if (!defined $_branches);
+ @_tags = 'tags' if (! @_tags);
+ @_branches = 'branches' if (! @_branches);
}
- if (defined $_trunk || defined $_branches || defined $_tags) {
+ if (defined $_trunk || @_branches || @_tags) {
return cmd_multi_init(@_);
}
my $url = shift or die "SVN repository location required ",
"as a command-line argument\n";
+ $url = canonicalize_url($url);
init_subdir(@_);
do_git_init_db();
'Cannot dcommit with a dirty index. Commit your changes first, '
. "or stash them with `git stash'.\n";
$head ||= 'HEAD';
+
+ my $old_head;
+ if ($head ne 'HEAD') {
+ $old_head = eval {
+ command_oneline([qw/symbolic-ref -q HEAD/])
+ };
+ if ($old_head) {
+ $old_head =~ s{^refs/heads/}{};
+ } else {
+ $old_head = eval { command_oneline(qw/rev-parse HEAD/) };
+ }
+ command(['checkout', $head], STDERR => 0);
+ }
+
my @refs;
- my ($url, $rev, $uuid, $gs) = working_head_info($head, \@refs);
+ my ($url, $rev, $uuid, $gs) = working_head_info('HEAD', \@refs);
unless ($gs) {
die "Unable to determine upstream SVN information from ",
"$head history.\nPerhaps the repository is empty.";
if (@diff) {
@refs = ();
my ($url_, $rev_, $uuid_, $gs_) =
- working_head_info($head, \@refs);
+ working_head_info('HEAD', \@refs);
my ($linear_refs_, $parents_) =
linearize_history($gs_, \@refs);
if (scalar(@$linear_refs) !=
}
}
}
+
+ if ($old_head) {
+ my $new_head = command_oneline(qw/rev-parse HEAD/);
+ my $new_is_symbolic = eval {
+ command_oneline(qw/symbolic-ref -q HEAD/);
+ };
+ if ($new_is_symbolic) {
+ print "dcommitted the branch ", $head, "\n";
+ } else {
+ print "dcommitted on a detached HEAD because you gave ",
+ "a revision argument.\n",
+ "The rewritten commit is: ", $new_head, "\n";
+ }
+ command(['checkout', $old_head], STDERR => 0);
+ }
+
unlink $gs->{index};
}
my ($src, $rev, undef, $gs) = working_head_info($head);
my $remote = Git::SVN::read_all_remotes()->{$gs->{repo_id}};
- my $glob = $remote->{ $_tag ? 'tags' : 'branches' };
+ my $allglobs = $remote->{ $_tag ? 'tags' : 'branches' };
+ my $glob;
+ if ($#{$allglobs} == 0) {
+ $glob = $allglobs->[0];
+ } else {
+ unless(defined $_branch_dest) {
+ die "Multiple ",
+ $_tag ? "tag" : "branch",
+ " paths defined for Subversion repository.\n",
+ "You must specify where you want to create the ",
+ $_tag ? "tag" : "branch",
+ " with the --destination argument.\n";
+ }
+ foreach my $g (@{$allglobs}) {
+ # SVN::Git::Editor could probably be moved to Git.pm..
+ my $re = SVN::Git::Editor::glob2pat($g->{path}->{left});
+ if ($_branch_dest =~ /$re/) {
+ $glob = $g;
+ last;
+ }
+ }
+ unless (defined $glob) {
+ die "Unknown ",
+ $_tag ? "tag" : "branch",
+ " destination $_branch_dest\n";
+ }
+ }
my ($lft, $rgt) = @{ $glob->{path} }{qw/left right/};
my $dst = join '/', $remote->{url}, $lft, $branch_name, ($rgt || ());
return $path;
}
+sub canonicalize_url {
+ my ($url) = @_;
+ $url =~ s#^([^:]+://[^/]*/)(.*)$#$1 . canonicalize_path($2)#e;
+ return $url;
+}
+
# get_svnprops(PATH)
# ------------------
# Helper for cmd_propget and cmd_proplist below.
sub cmd_multi_init {
my $url = shift;
- unless (defined $_trunk || defined $_branches || defined $_tags) {
+ unless (defined $_trunk || @_branches || @_tags) {
usage(1);
}
$_prefix = '' unless defined $_prefix;
if (defined $url) {
- $url =~ s#/+$##;
+ $url = canonicalize_url($url);
init_subdir(@_);
}
do_git_init_db();
undef, $trunk_ref);
}
}
- return unless defined $_branches || defined $_tags;
+ return unless @_branches || @_tags;
my $ra = $url ? Git::SVN::Ra->new($url) : undef;
- complete_url_ls_init($ra, $_branches, '--branches/-b', $_prefix);
- complete_url_ls_init($ra, $_tags, '--tags/-t', $_prefix . 'tags/');
+ foreach my $path (@_branches) {
+ complete_url_ls_init($ra, $path, '--branches/-b', $_prefix);
+ }
+ foreach my $path (@_tags) {
+ complete_url_ls_init($ra, $path, '--tags/-t', $_prefix.'tags/');
+ }
}
sub cmd_multi_fetch {
print $result, "\n";
}
+sub cmd_reset {
+ my $target = shift || $_revision or die "SVN revision required\n";
+ $target = $1 if $target =~ /^r(\d+)$/;
+ $target =~ /^\d+$/ or die "Numeric SVN revision expected\n";
+ my ($url, $rev, $uuid, $gs) = working_head_info('HEAD');
+ unless ($gs) {
+ die "Unable to determine upstream SVN information from ".
+ "history\n";
+ }
+ my ($r, $c) = $gs->find_rev_before($target, not $_fetch_parent);
+ $gs->rev_map_set($r, $c, 'reset', $uuid);
+ print "r$r = $c ($gs->{ref_id})\n";
+}
+
########################### utility functions #########################
sub rebase_cmd {
die "--prefix='$pfx' must have a trailing slash '/'\n";
}
command_noisy('config',
+ '--add',
"svn-remote.$gs->{repo_id}.$n",
"$remote_path:refs/remotes/$pfx*" .
('/*' x (($remote_path =~ tr/*/*/) - 1)) );
# read the max revs for wildcard expansion (branches/*, tags/*)
foreach my $t (qw/branches tags/) {
defined $remote->{$t} or next;
- push @globs, $remote->{$t};
+ push @globs, @{$remote->{$t}};
+
my $max_rev = eval { tmp_config(qw/--int --get/,
"svn-remote.$repo_id.${t}-maxRev") };
if (defined $max_rev && ($max_rev < $base)) {
} elsif (m!^(.+)\.(branches|tags)=
(.*):refs/remotes/(.+)\s*$/!x) {
my ($p, $g) = ($3, $4);
- my $rs = $r->{$1}->{$2} = {
- t => $2,
- remote => $1,
- path => Git::SVN::GlobSpec->new($p),
- ref => Git::SVN::GlobSpec->new($g) };
+ my $rs = {
+ t => $2,
+ remote => $1,
+ path => Git::SVN::GlobSpec->new($p),
+ ref => Git::SVN::GlobSpec->new($g) };
if (length($rs->{ref}->{right}) != 0) {
die "The '*' glob character must be the last ",
"character of '$g'\n";
}
+ push @{ $r->{$1}->{$2} }, $rs;
}
}
next if defined $repos_root && $repos_root ne $u;
my $fetch = $remotes->{$repo_id}->{fetch} || {};
- foreach (qw/branches tags/) {
- resolve_local_globs($u, $fetch,
- $remotes->{$repo_id}->{$_});
+ foreach my $t (qw/branches tags/) {
+ foreach my $globspec (@{$remotes->{$repo_id}->{$t}}) {
+ resolve_local_globs($u, $fetch, $globspec);
+ }
}
my $p = $path;
my $rwr = rewrite_root({repo_id => $repo_id});
croak "write: $!";
}
+sub _rev_map_reset {
+ my ($fh, $rev, $commit) = @_;
+ my $c = _rev_map_get($fh, $rev);
+ $c eq $commit or die "_rev_map_reset(@_) commit $c does not match!\n";
+ my $offset = sysseek($fh, 0, SEEK_CUR) or croak "seek: $!";
+ truncate $fh, $offset or croak "truncate: $!";
+}
+
sub mkfile {
my ($path) = @_;
unless (-e $path) {
my $db = $self->map_path($uuid);
my $db_lock = "$db.lock";
my $sig;
+ $update_ref ||= 0;
if ($update_ref) {
$SIG{INT} = $SIG{HUP} = $SIG{TERM} = $SIG{ALRM} = $SIG{PIPE} =
$SIG{USR1} = $SIG{USR2} = sub { $sig = $_[0] };
sysopen(my $fh, $db_lock, O_RDWR | O_CREAT)
or croak "Couldn't open $db_lock: $!\n";
- _rev_map_set($fh, $rev, $commit);
+ $update_ref eq 'reset' ? _rev_map_reset($fh, $rev, $commit) :
+ _rev_map_set($fh, $rev, $commit);
if ($sync) {
$fh->flush or die "Couldn't flush $db_lock: $!\n";
$fh->sync or die "Couldn't sync $db_lock: $!\n";
close $fh or croak $!;
if ($update_ref) {
$_head = $self;
- command_noisy('update-ref', '-m', "r$rev",
+ my $note = "";
+ $note = " ($update_ref)" if ($update_ref !~ /^\d*$/);
+ command_noisy('update-ref', '-m', "r$rev$note",
$self->refname, $commit);
}
rename $db_lock, $db or die "rev_map_set(@_): ", "Failed to rename: ",
return undef unless -e $map_path;
sysopen(my $fh, $map_path, O_RDONLY) or croak "open: $!";
+ my $c = _rev_map_get($fh, $rev);
+ close($fh) or croak "close: $!";
+ $c
+}
+
+sub _rev_map_get {
+ my ($fh, $rev) = @_;
+
binmode $fh or croak "binmode: $!";
my $size = (stat($fh))[7];
($size % 24) == 0 or croak "inconsistent size: $size";
if ($size == 0) {
- close $fh or croak "close: $fh";
return undef;
}
} elsif ($r > $rev) {
$u = $i - 24;
} else { # $r == $rev
- close($fh) or croak "close: $!";
return $c eq ('0' x 40) ? undef : $c;
}
}
- close($fh) or croak "close: $!";
undef;
}
my ($self, $rev, $eq_ok, $min_rev) = @_;
--$rev unless $eq_ok;
$min_rev ||= 1;
+ my $max_rev = $self->rev_map_max;
+ $rev = $max_rev if ($rev > $max_rev);
while ($rev >= $min_rev) {
if (my $c = $self->rev_map_get($rev)) {
return ($rev, $c);
--- /dev/null
+#!/bin/sh
+#
+# Copyright (c) 2009 Marc Branchaud
+#
+
+test_description='git svn multiple branch and tag paths in the svn repo'
+. ./lib-git-svn.sh
+
+test_expect_success 'setup svnrepo' '
+ mkdir project \
+ project/trunk \
+ project/b_one \
+ project/b_two \
+ project/tags_A \
+ project/tags_B &&
+ echo 1 > project/trunk/a.file &&
+ svn_cmd import -m "$test_description" project "$svnrepo/project" &&
+ rm -rf project &&
+ svn_cmd cp -m "Branch 1" "$svnrepo/project/trunk" \
+ "$svnrepo/project/b_one/first" &&
+ svn_cmd cp -m "Tag 1" "$svnrepo/project/trunk" \
+ "$svnrepo/project/tags_A/1.0" &&
+ svn_cmd co "$svnrepo/project" svn_project &&
+ ( cd svn_project &&
+ echo 2 > trunk/a.file &&
+ svn_cmd ci -m "Change 1" trunk/a.file &&
+ svn_cmd cp -m "Branch 2" "$svnrepo/project/trunk" \
+ "$svnrepo/project/b_one/second" &&
+ svn_cmd cp -m "Tag 2" "$svnrepo/project/trunk" \
+ "$svnrepo/project/tags_A/2.0" &&
+ echo 3 > trunk/a.file &&
+ svn_cmd ci -m "Change 2" trunk/a.file &&
+ svn_cmd cp -m "Branch 3" "$svnrepo/project/trunk" \
+ "$svnrepo/project/b_two/1" &&
+ svn_cmd cp -m "Tag 3" "$svnrepo/project/trunk" \
+ "$svnrepo/project/tags_A/3.0" &&
+ echo 4 > trunk/a.file &&
+ svn_cmd ci -m "Change 3" trunk/a.file &&
+ svn_cmd cp -m "Branch 4" "$svnrepo/project/trunk" \
+ "$svnrepo/project/b_two/2" &&
+ svn_cmd cp -m "Tag 4" "$svnrepo/project/trunk" \
+ "$svnrepo/project/tags_A/4.0" &&
+ svn_cmd up &&
+ echo 5 > b_one/first/a.file &&
+ svn_cmd ci -m "Change 4" b_one/first/a.file &&
+ svn_cmd cp -m "Tag 5" "$svnrepo/project/b_one/first" \
+ "$svnrepo/project/tags_B/v5" &&
+ echo 6 > b_one/second/a.file &&
+ svn_cmd ci -m "Change 5" b_one/second/a.file &&
+ svn_cmd cp -m "Tag 6" "$svnrepo/project/b_one/second" \
+ "$svnrepo/project/tags_B/v6" &&
+ echo 7 > b_two/1/a.file &&
+ svn_cmd ci -m "Change 6" b_two/1/a.file &&
+ svn_cmd cp -m "Tag 7" "$svnrepo/project/b_two/1" \
+ "$svnrepo/project/tags_B/v7" &&
+ echo 8 > b_two/2/a.file &&
+ svn_cmd ci -m "Change 7" b_two/2/a.file &&
+ svn_cmd cp -m "Tag 8" "$svnrepo/project/b_two/2" \
+ "$svnrepo/project/tags_B/v8"
+ )
+'
+
+test_expect_success 'clone multiple branch and tag paths' '
+ git svn clone -T trunk \
+ -b b_one/* --branches b_two/* \
+ -t tags_A/* --tags tags_B \
+ "$svnrepo/project" git_project &&
+ ( cd git_project &&
+ git rev-parse refs/remotes/first &&
+ git rev-parse refs/remotes/second &&
+ git rev-parse refs/remotes/1 &&
+ git rev-parse refs/remotes/2 &&
+ git rev-parse refs/remotes/tags/1.0 &&
+ git rev-parse refs/remotes/tags/2.0 &&
+ git rev-parse refs/remotes/tags/3.0 &&
+ git rev-parse refs/remotes/tags/4.0 &&
+ git rev-parse refs/remotes/tags/v5 &&
+ git rev-parse refs/remotes/tags/v6 &&
+ git rev-parse refs/remotes/tags/v7 &&
+ git rev-parse refs/remotes/tags/v8
+ )
+'
+
+test_expect_success 'Multiple branch or tag paths require -d' '
+ ( cd git_project &&
+ test_must_fail git svn branch -m "No new branch" Nope &&
+ test_must_fail git svn tag -m "No new tag" Tagless &&
+ test_must_fail git rev-parse refs/remotes/Nope &&
+ test_must_fail git rev-parse refs/remotes/tags/Tagless
+ ) &&
+ ( cd svn_project &&
+ svn_cmd up &&
+ test_must_fail test -d b_one/Nope &&
+ test_must_fail test -d b_two/Nope &&
+ test_must_fail test -d tags_A/Tagless &&
+ test_must_fail test -d tags_B/Tagless
+ )
+'
+
+test_expect_success 'create new branches and tags' '
+ ( cd git_project &&
+ git svn branch -m "New branch 1" -d project/b_one New1 ) &&
+ ( cd svn_project &&
+ svn_cmd up && test -e b_one/New1/a.file ) &&
+
+ ( cd git_project &&
+ git svn branch -m "New branch 2" -d project/b_two New2 ) &&
+ ( cd svn_project &&
+ svn_cmd up && test -e b_two/New2/a.file ) &&
+
+ ( cd git_project &&
+ git svn branch -t -m "New tag 1" -d project/tags_A Tag1 ) &&
+ ( cd svn_project &&
+ svn_cmd up && test -e tags_A/Tag1/a.file ) &&
+
+ ( cd git_project &&
+ git svn tag -m "New tag 2" -d project/tags_B Tag2 ) &&
+ ( cd svn_project &&
+ svn_cmd up && test -e tags_B/Tag2/a.file )
+'
+
+test_done