git-svn: New flag to emulate empty directories
Adds a --preserve-empty-dirs flag to the clone operation that will detect empty directories in the remote Subversion repository and create placeholder files in the corresponding local Git directories. This allows "empty" directories to exist in the history of a Git repository. Also adds the --placeholder-file flag to control the name of any placeholder files created. Default value is ".gitignore". Signed-off-by: Ray Chen <rchen@cs.umd.edu> Acked-by: Eric Wong <normalperson@yhbt.net>
Ray Chen committed
Jul 20, 2011 at 18:37 UTC
40a1530c07a6a720b6cfa52c6132274fac4e4112
3 files changed
+308
-4
Documentation/git-svn.txt
+11
@@ -157,6 +157,17 @@ Skip "branches" and "tags" of first level directories;;
157
affecting the working tree; and the 'rebase' command will be
158
able to update the working tree with the latest changes.
159
160
+--preserve-empty-dirs;;
161
+ Create a placeholder file in the local Git repository for each
162
+ empty directory fetched from Subversion. This includes directories
163
+ that become empty by removing all entries in the Subversion
164
+ repository (but not the directory itself). The placeholder files
165
+ are also tracked and removed when no longer necessary.
166
+
167
+--placeholder-filename=<filename>;;
168
+ Set the name of placeholder files created by --preserve-empty-dirs.
169
+ Default: ".gitignore"
170
+
171
'rebase'::
172
This fetches revisions from the SVN parent of the current HEAD
173
and rebases the current (uncommitted to SVN) work against it.
git-svn.perl
+144
-4
@@ -89,6 +89,7 @@ my ($_stdin, $_help, $_edit,
89
$_prefix, $_no_checkout, $_url, $_verbose,
90
$_git_format, $_commit_url, $_tag, $_merge_info);
91
$Git::SVN::_follow_parent = 1;
92
+$SVN::Git::Fetcher::_placeholder_filename = ".gitignore";
93
$_q ||= 0;
94
my %remote_opts = ( 'username=s' => \$Git::SVN::Prompt::_username,
95
'config-dir=s' => \$Git::SVN::Ra::config_dir,
@@ -139,6 +140,10 @@ my %cmd = (
140
%fc_opts } ],
141
clone => [ \&cmd_clone, "Initialize and fetch revisions",
142
{ 'revision|r=s' => \$_revision,
143
+ 'preserve-empty-dirs' =>
144
+ \$SVN::Git::Fetcher::_preserve_empty_dirs,
145
+ 'placeholder-filename=s' =>
146
+ \$SVN::Git::Fetcher::_placeholder_filename,
147
%fc_opts, %init_opts } ],
148
init => [ \&cmd_init, "Initialize a repo for tracking" .
149
" (requires URL argument)",
@@ -386,6 +391,12 @@ sub do_git_init_db {
391
my $ignore_regex = \$SVN::Git::Fetcher::_ignore_regex;
392
command_noisy('config', "$pfx.ignore-paths", $$ignore_regex)
393
if defined $$ignore_regex;
394
+
395
+ if (defined $SVN::Git::Fetcher::_preserve_empty_dirs) {
396
+ my $fname = \$SVN::Git::Fetcher::_placeholder_filename;
397
+ command_noisy('config', "$pfx.preserve-empty-dirs", 'true');
398
+ command_noisy('config', "$pfx.placeholder-filename", $$fname);
399
+ }
400
}
401
402
sub init_subdir {
@@ -4080,12 +4091,13 @@ sub _read_password {
4091
}
4092
4093
package SVN::Git::Fetcher;
4083
-use vars qw/@ISA/;
4094
+use vars qw/@ISA $_ignore_regex $_preserve_empty_dirs $_placeholder_filename
4095
+ @deleted_gpath %added_placeholder $repo_id/;
4096
use strict;
4097
use warnings;
4098
use Carp qw/croak/;
4099
+use File::Basename qw/dirname/;
4100
use IO::File qw//;
4088
-use vars qw/$_ignore_regex/;
4101
4102
# file baton members: path, mode_a, mode_b, pool, fh, blob, base
4103
sub new {
@@ -4097,8 +4109,34 @@ sub new {
4109
$self->{empty_symlinks} =
4110
_mark_empty_symlinks($git_svn, $switch_path);
4111
}
4100
- $self->{ignore_regex} = eval { command_oneline('config', '--get',
4101
- "svn-remote.$git_svn->{repo_id}.ignore-paths") };
4112
+
4113
+ # some options are read globally, but can be overridden locally
4114
+ # per [svn-remote "..."] section. Command-line options will *NOT*
4115
+ # override options set in an [svn-remote "..."] section
4116
+ $repo_id = $git_svn->{repo_id};
4117
+ my $k = "svn-remote.$repo_id.ignore-paths";
4118
+ my $v = eval { command_oneline('config', '--get', $k) };
4119
+ $self->{ignore_regex} = $v;
4120
+
4121
+ $k = "svn-remote.$repo_id.preserve-empty-dirs";
4122
+ $v = eval { command_oneline('config', '--get', '--bool', $k) };
4123
+ if ($v && $v eq 'true') {
4124
+ $_preserve_empty_dirs = 1;
4125
+ $k = "svn-remote.$repo_id.placeholder-filename";
4126
+ $v = eval { command_oneline('config', '--get', $k) };
4127
+ $_placeholder_filename = $v;
4128
+ }
4129
+
4130
+ # Load the list of placeholder files added during previous invocations.
4131
+ $k = "svn-remote.$repo_id.added-placeholder";
4132
+ $v = eval { command_oneline('config', '--get-all', $k) };
4133
+ if ($_preserve_empty_dirs && $v) {
4134
+ # command() prints errors to stderr, so we only call it if
4135
+ # command_oneline() succeeded.
4136
+ my @v = command('config', '--get-all', $k);
4137
+ $added_placeholder{ dirname($_) } = $_ foreach @v;
4138
+ }
4139
+
4140
$self->{empty} = {};
4141
$self->{dir_prop} = {};
4142
$self->{file_prop} = {};
@@ -4227,6 +4265,8 @@ sub delete_entry {
4265
$self->{gii}->remove($gpath);
4266
print "\tD\t$gpath\n" unless $::_q;
4267
}
4268
+ # Don't add to @deleted_gpath if we're deleting a placeholder file.
4269
+ push @deleted_gpath, $gpath unless $added_placeholder{dirname($path)};
4270
$self->{empty}->{$path} = 0;
4271
undef;
4272
}
@@ -4259,7 +4299,15 @@ sub add_file {
4299
my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#);
4300
delete $self->{empty}->{$dir};
4301
$mode = '100644';
4302
+
4303
+ if ($added_placeholder{$dir}) {
4304
+ # Remove our placeholder file, if we created one.
4305
+ delete_entry($self, $added_placeholder{$dir})
4306
+ unless $path eq $added_placeholder{$dir};
4307
+ delete $added_placeholder{$dir}
4308
+ }
4309
}
4310
+
4311
{ path => $path, mode_a => $mode, mode_b => $mode,
4312
pool => SVN::Pool->new, action => 'A' };
4313
}
@@ -4277,6 +4325,7 @@ sub add_directory {
4325
chomp;
4326
$self->{gii}->remove($_);
4327
print "\tD\t$_\n" unless $::_q;
4328
+ push @deleted_gpath, $gpath;
4329
}
4330
command_close_pipe($ls, $ctx);
4331
$self->{empty}->{$path} = 0;
@@ -4284,6 +4333,13 @@ sub add_directory {
4333
my ($dir, $file) = ($path =~ m#^(.*?)/?([^/]+)$#);
4334
delete $self->{empty}->{$dir};
4335
$self->{empty}->{$path} = 1;
4336
+
4337
+ if ($added_placeholder{$dir}) {
4338
+ # Remove our placeholder file, if we created one.
4339
+ delete_entry($self, $added_placeholder{$dir});
4340
+ delete $added_placeholder{$dir}
4341
+ }
4342
+
4343
out:
4344
{ path => $path };
4345
}
@@ -4447,12 +4503,96 @@ sub abort_edit {
4503
4504
sub close_edit {
4505
my $self = shift;
4506
+
4507
+ if ($_preserve_empty_dirs) {
4508
+ my @empty_dirs;
4509
+
4510
+ # Any entry flagged as empty that also has an associated
4511
+ # dir_prop represents a newly created empty directory.
4512
+ foreach my $i (keys %{$self->{empty}}) {
4513
+ push @empty_dirs, $i if exists $self->{dir_prop}->{$i};
4514
+ }
4515
+
4516
+ # Search for directories that have become empty due subsequent
4517
+ # file deletes.
4518
+ push @empty_dirs, $self->find_empty_directories();
4519
+
4520
+ # Finally, add a placeholder file to each empty directory.
4521
+ $self->add_placeholder_file($_) foreach (@empty_dirs);
4522
+
4523
+ $self->stash_placeholder_list();
4524
+ }
4525
+
4526
$self->{git_commit_ok} = 1;
4527
$self->{nr} = $self->{gii}->{nr};
4528
delete $self->{gii};
4529
$self->SUPER::close_edit(@_);
4530
}
4531
4532
+sub find_empty_directories {
4533
+ my ($self) = @_;
4534
+ my @empty_dirs;
4535
+ my %dirs = map { dirname($_) => 1 } @deleted_gpath;
4536
+
4537
+ foreach my $dir (sort keys %dirs) {
4538
+ next if $dir eq ".";
4539
+
4540
+ # If there have been any additions to this directory, there is
4541
+ # no reason to check if it is empty.
4542
+ my $skip_added = 0;
4543
+ foreach my $t (qw/dir_prop file_prop/) {
4544
+ foreach my $path (keys %{ $self->{$t} }) {
4545
+ if (exists $self->{$t}->{dirname($path)}) {
4546
+ $skip_added = 1;
4547
+ last;
4548
+ }
4549
+ }
4550
+ last if $skip_added;
4551
+ }
4552
+ next if $skip_added;
4553
+
4554
+ # Use `git ls-tree` to get the filenames of this directory
4555
+ # that existed prior to this particular commit.
4556
+ my $ls = command('ls-tree', '-z', '--name-only',
4557
+ $self->{c}, "$dir/");
4558
+ my %files = map { $_ => 1 } split(/\0/, $ls);
4559
+
4560
+ # Remove the filenames that were deleted during this commit.
4561
+ delete $files{$_} foreach (@deleted_gpath);
4562
+
4563
+ # Report the directory if there are no filenames left.
4564
+ push @empty_dirs, $dir unless (scalar %files);
4565
+ }
4566
+ @empty_dirs;
4567
+}
4568
+
4569
+sub add_placeholder_file {
4570
+ my ($self, $dir) = @_;
4571
+ my $path = "$dir/$_placeholder_filename";
4572
+ my $gpath = $self->git_path($path);
4573
+
4574
+ my $fh = $::_repository->temp_acquire($gpath);
4575
+ my $hash = $::_repository->hash_and_insert_object(Git::temp_path($fh));
4576
+ Git::temp_release($fh, 1);
4577
+ $self->{gii}->update('100644', $hash, $gpath) or croak $!;
4578
+
4579
+ # The directory should no longer be considered empty.
4580
+ delete $self->{empty}->{$dir} if exists $self->{empty}->{$dir};
4581
+
4582
+ # Keep track of any placeholder files we create.
4583
+ $added_placeholder{$dir} = $path;
4584
+}
4585
+
4586
+sub stash_placeholder_list {
4587
+ my ($self) = @_;
4588
+ my $k = "svn-remote.$repo_id.added-placeholder";
4589
+ my $v = eval { command_oneline('config', '--get-all', $k) };
4590
+ command_noisy('config', '--unset-all', $k) if $v;
4591
+ foreach (values %added_placeholder) {
4592
+ command_noisy('config', '--add', $k, $_);
4593
+ }
4594
+}
4595
+
4596
package SVN::Git::Editor;
4597
use vars qw/@ISA $_rmdir $_cp_similarity $_find_copies_harder $_rename_limit/;
4598
use strict;
t/t9160-git-svn-preserve-empty-dirs.sh
new
+153
@@ -0,0 +1,153 @@
1
+#!/bin/sh
2
+#
3
+# Copyright (c) 2011 Ray Chen
4
+#
5
+
6
+test_description='git svn test (option --preserve-empty-dirs)
7
+
8
+This test uses git to clone a Subversion repository that contains empty
9
+directories, and checks that corresponding directories are created in the
10
+local Git repository with placeholder files.'
11
+
12
+. ./lib-git-svn.sh
13
+
14
+say 'define NO_SVN_TESTS to skip git svn tests'
15
+GIT_REPO=git-svn-repo
16
+
17
+test_expect_success 'initialize source svn repo containing empty dirs' '
18
+ svn_cmd mkdir -m x "$svnrepo"/trunk &&
19
+ svn_cmd co "$svnrepo"/trunk "$SVN_TREE" &&
20
+ (
21
+ cd "$SVN_TREE" &&
22
+ mkdir -p 1 2 3/a 3/b 4 5 6 &&
23
+ echo "First non-empty file" > 2/file1.txt &&
24
+ echo "Second non-empty file" > 2/file2.txt &&
25
+ echo "Third non-empty file" > 3/a/file1.txt &&
26
+ echo "Fourth non-empty file" > 3/b/file1.txt &&
27
+ svn_cmd add 1 2 3 4 5 6 &&
28
+ svn_cmd commit -m "initial commit" &&
29
+
30
+ mkdir 4/a &&
31
+ svn_cmd add 4/a &&
32
+ svn_cmd commit -m "nested empty directory" &&
33
+ mkdir 4/a/b &&
34
+ svn_cmd add 4/a/b &&
35
+ svn_cmd commit -m "deeply nested empty directory" &&
36
+ mkdir 4/a/b/c &&
37
+ svn_cmd add 4/a/b/c &&
38
+ svn_cmd commit -m "really deeply nested empty directory" &&
39
+ echo "Kill the placeholder file" > 4/a/b/c/foo &&
40
+ svn_cmd add 4/a/b/c/foo &&
41
+ svn_cmd commit -m "Regular file to remove placeholder" &&
42
+
43
+ svn_cmd del 2/file2.txt &&
44
+ svn_cmd del 3/b &&
45
+ svn_cmd commit -m "delete non-last entry in directory" &&
46
+
47
+ svn_cmd del 2/file1.txt &&
48
+ svn_cmd del 3/a &&
49
+ svn_cmd commit -m "delete last entry in directory" &&
50
+
51
+ echo "Conflict file" > 5/.placeholder &&
52
+ mkdir 6/.placeholder &&
53
+ svn_cmd add 5/.placeholder 6/.placeholder &&
54
+ svn_cmd commit -m "Placeholder Namespace conflict"
55
+ ) &&
56
+ rm -rf "$SVN_TREE"
57
+'
58
+
59
+test_expect_success 'clone svn repo with --preserve-empty-dirs' '
60
+ git svn clone "$svnrepo"/trunk --preserve-empty-dirs "$GIT_REPO"
61
+'
62
+
63
+# "$GIT_REPO"/1 should only contain the placeholder file.
64
+test_expect_success 'directory empty from inception' '
65
+ test -f "$GIT_REPO"/1/.gitignore &&
66
+ test $(find "$GIT_REPO"/1 -type f | wc -l) = "1"
67
+'
68
+
69
+# "$GIT_REPO"/2 and "$GIT_REPO"/3 should only contain the placeholder file.
70
+test_expect_success 'directory empty from subsequent svn commit' '
71
+ test -f "$GIT_REPO"/2/.gitignore &&
72
+ test $(find "$GIT_REPO"/2 -type f | wc -l) = "1" &&
73
+ test -f "$GIT_REPO"/3/.gitignore &&
74
+ test $(find "$GIT_REPO"/3 -type f | wc -l) = "1"
75
+'
76
+
77
+# No placeholder files should exist in "$GIT_REPO"/4, even though one was
78
+# generated for every sub-directory at some point in the repo's history.
79
+test_expect_success 'add entry to previously empty directory' '
80
+ test $(find "$GIT_REPO"/4 -type f | wc -l) = "1" &&
81
+ test -f "$GIT_REPO"/4/a/b/c/foo
82
+'
83
+
84
+# The HEAD~2 commit should not have introduced .gitignore placeholder files.
85
+test_expect_success 'remove non-last entry from directory' '
86
+ (
87
+ cd "$GIT_REPO" &&
88
+ git checkout HEAD~2
89
+ ) &&
90
+ test_must_fail test -f "$GIT_REPO"/2/.gitignore &&
91
+ test_must_fail test -f "$GIT_REPO"/3/.gitignore
92
+'
93
+
94
+# After re-cloning the repository with --placeholder-file specified, there
95
+# should be 5 files named ".placeholder" in the local Git repo.
96
+test_expect_success 'clone svn repo with --placeholder-file specified' '
97
+ rm -rf "$GIT_REPO" &&
98
+ git svn clone "$svnrepo"/trunk --preserve-empty-dirs \
99
+ --placeholder-file=.placeholder "$GIT_REPO" &&
100
+ find "$GIT_REPO" -type f -name ".placeholder" &&
101
+ test $(find "$GIT_REPO" -type f -name ".placeholder" | wc -l) = "5"
102
+'
103
+
104
+# "$GIT_REPO"/5/.placeholder should be a file, and non-empty.
105
+test_expect_success 'placeholder namespace conflict with file' '
106
+ test -s "$GIT_REPO"/5/.placeholder
107
+'
108
+
109
+# "$GIT_REPO"/6/.placeholder should be a directory, and the "$GIT_REPO"/6 tree
110
+# should only contain one file: the placeholder.
111
+test_expect_success 'placeholder namespace conflict with directory' '
112
+ test -d "$GIT_REPO"/6/.placeholder &&
113
+ test -f "$GIT_REPO"/6/.placeholder/.placeholder &&
114
+ test $(find "$GIT_REPO"/6 -type f | wc -l) = "1"
115
+'
116
+
117
+# Prepare a second set of svn commits to test persistence during rebase.
118
+test_expect_success 'second set of svn commits and rebase' '
119
+ svn_cmd co "$svnrepo"/trunk "$SVN_TREE" &&
120
+ (
121
+ cd "$SVN_TREE" &&
122
+ mkdir -p 7 &&
123
+ echo "This should remove placeholder" > 1/file1.txt &&
124
+ echo "This should not remove placeholder" > 5/file1.txt &&
125
+ svn_cmd add 7 1/file1.txt 5/file1.txt &&
126
+ svn_cmd commit -m "subsequent svn commit for persistence tests"
127
+ ) &&
128
+ rm -rf "$SVN_TREE" &&
129
+ (
130
+ cd "$GIT_REPO" &&
131
+ git svn rebase
132
+ )
133
+'
134
+
135
+# Check that --preserve-empty-dirs and --placeholder-file flag state
136
+# stays persistent over multiple invocations.
137
+test_expect_success 'flag persistence during subsqeuent rebase' '
138
+ test -f "$GIT_REPO"/7/.placeholder &&
139
+ test $(find "$GIT_REPO"/7 -type f | wc -l) = "1"
140
+'
141
+
142
+# Check that placeholder files are properly removed when unnecessary,
143
+# even across multiple invocations.
144
+test_expect_success 'placeholder list persistence during subsqeuent rebase' '
145
+ test -f "$GIT_REPO"/1/file1.txt &&
146
+ test $(find "$GIT_REPO"/1 -type f | wc -l) = "1" &&
147
+
148
+ test -f "$GIT_REPO"/5/file1.txt &&
149
+ test -f "$GIT_REPO"/5/.placeholder &&
150
+ test $(find "$GIT_REPO"/5 -type f | wc -l) = "2"
151
+'
152
+
153
+test_done