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