From: Amitai Schlair Date: Sun, 30 Aug 2009 07:02:15 +0000 (-0400) Subject: Merge branch 'master' of git://github.com/joeyh/ikiwiki X-Git-Url: https://sipb.mit.edu/gitweb.cgi/ikiwiki.git/commitdiff_plain/c36d2fa896e9ea42c0b6b0135ba04b4f4f60950f?hp=517432273b96fc9e6bad9b7667ef6d1b04c699ee Merge branch 'master' of git://github.com/joeyh/ikiwiki --- diff --git a/IkiWiki.pm b/IkiWiki.pm index b8e89b73f..453bc9f8c 100644 --- a/IkiWiki.pm +++ b/IkiWiki.pm @@ -336,7 +336,7 @@ sub getsetup () { qr/\.x?html?$/, qr/\.ikiwiki-new$/, qr/(^|\/).svn\//, qr/.arch-ids\//, qr/{arch}\//, qr/(^|\/)_MTN\//, qr/(^|\/)_darcs\//, - qr/\.dpkg-tmp$/], + qr/(^|\/)CVS\//, qr/\.dpkg-tmp$/], description => "regexps of source files to ignore", safe => 0, rebuild => 1, diff --git a/IkiWiki/Plugin/cvs.pm b/IkiWiki/Plugin/cvs.pm new file mode 100644 index 000000000..66e9463be --- /dev/null +++ b/IkiWiki/Plugin/cvs.pm @@ -0,0 +1,443 @@ +#!/usr/pkg/bin/perl +package IkiWiki::Plugin::cvs; + +use warnings; +use strict; +use IkiWiki; + +sub import { + hook(type => "checkconfig", id => "cvs", call => \&checkconfig); + hook(type => "getsetup", id => "cvs", call => \&getsetup); + hook(type => "rcs", id => "rcs_update", call => \&rcs_update); + hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit); + hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit); + hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged); + hook(type => "rcs", id => "rcs_add", call => \&rcs_add); + hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove); + hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename); + hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges); + hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff); + hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime); +} + +sub checkconfig () { + if (! defined $config{cvspath}) { + $config{cvspath}="ikiwiki"; + } + if (exists $config{cvspath}) { + # code depends on the path not having extraneous slashes + $config{cvspath}=~tr#/#/#s; + $config{cvspath}=~s/\/$//; + $config{cvspath}=~s/^\///; + } + if (defined $config{cvs_wrapper} && length $config{cvs_wrapper}) { + push @{$config{wrappers}}, { + wrapper => $config{cvs_wrapper}, + wrappermode => (defined $config{cvs_wrappermode} ? $config{cvs_wrappermode} : "04755"), + }; + } +} + +sub getsetup () { + return + plugin => { + safe => 0, # rcs plugin + rebuild => undef, + }, + cvsrepo => { + type => "string", + example => "/cvs/wikirepo", + description => "cvs repository location", + safe => 0, # path + rebuild => 0, + }, + cvspath => { + type => "string", + example => "ikiwiki", + description => "path inside repository where the wiki is located", + safe => 0, # paranoia + rebuild => 0, + }, + cvs_wrapper => { + type => "string", + example => "/cvs/wikirepo/CVSROOT/post-commit", + description => "cvs post-commit hook to generate (triggered by CVSROOT/loginfo entry", + safe => 0, # file + rebuild => 0, + }, + cvs_wrappermode => { + type => "string", + example => '04755', + description => "mode for cvs_wrapper (can safely be made suid)", + safe => 0, + rebuild => 0, + }, + historyurl => { + type => "string", + example => "http://cvs.example.org/cvsweb.cgi/ikiwiki/[[file]]", + description => "cvsweb url to show file history ([[file]] substituted)", + safe => 1, + rebuild => 1, + }, + diffurl => { + type => "string", + example => "http://cvs.example.org/cvsweb.cgi/ikiwiki/[[file]].diff?r1=text&tr1=[[r1]]&r2=text&tr2=[[r2]]", + description => "cvsweb url to show a diff ([[file]], [[r1]], and [[r2]] substituted)", + safe => 1, + rebuild => 1, + }, +} + +sub cvs_info ($$) { + my $field=shift; + my $file=shift; + + chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!"); + + my $info=`cvs status $file`; + my ($ret)=$info=~/^\s*$field:\s*(\S+)/m; + return $ret; +} + +sub cvs_runcvs(@) { + my ($cmd) = @_; + unshift @$cmd, 'cvs', '-Q'; + + eval q{use IPC::Cmd}; + error($@) if $@; + + chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!"); + + my ($success, $error_code, $full_buf, $stdout_buf, $stderr_buf) = + IPC::Cmd::run(command => $cmd, verbose => 0); + if (! $success) { + warn(join(" ", @$cmd) . " exited with code $error_code\n"); + warn(join "", @$stderr_buf); + } + return $success; +} + +sub cvs_shquote_commit ($) { + my $message = shift; + + eval q{use String::ShellQuote}; + error($@) if $@; + + return shell_quote(IkiWiki::possibly_foolish_untaint($message)); +} + +sub cvs_is_controlling { + my $dir=shift; + $dir=$config{srcdir} unless defined($dir); + return (-d "$dir/CVS") ? 1 : 0; +} + +sub rcs_update () { + return unless cvs_is_controlling; + cvs_runcvs(['update', '-dP']); +} + +sub rcs_prepedit ($) { + # Prepares to edit a file under revision control. Returns a token + # that must be passed into rcs_commit when the file is ready + # for committing. + # The file is relative to the srcdir. + my $file=shift; + + return unless cvs_is_controlling; + + # For cvs, return the revision of the file when + # editing begins. + my $rev=cvs_info("Repository revision", "$file"); + return defined $rev ? $rev : ""; +} + +sub rcs_commit ($$$;$$) { + # Tries to commit the page; returns undef on _success_ and + # a version of the page with the rcs's conflict markers on failure. + # The file is relative to the srcdir. + my $file=shift; + my $message=shift; + my $rcstoken=shift; + my $user=shift; + my $ipaddr=shift; + + return unless cvs_is_controlling; + + if (defined $user) { + $message="web commit by $user".(length $message ? ": $message" : ""); + } + elsif (defined $ipaddr) { + $message="web commit from $ipaddr".(length $message ? ": $message" : ""); + } + + # Check to see if the page has been changed by someone + # else since rcs_prepedit was called. + my ($oldrev)=$rcstoken=~/^([0-9]+)$/; # untaint + my $rev=cvs_info("Repository revision", "$config{srcdir}/$file"); + if (defined $rev && defined $oldrev && $rev != $oldrev) { + # Merge their changes into the file that we've + # changed. + cvs_runcvs(['update', $file]) || + warn("cvs merge from $oldrev to $rev failed\n"); + } + + if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) { + my $conflict=readfile("$config{srcdir}/$file"); + cvs_runcvs(['update', '-C', $file]) || + warn("cvs revert failed\n"); + return $conflict; + } + + return undef # success +} + +sub rcs_commit_staged ($$$) { + # Commits all staged changes. Changes can be staged using rcs_add, + # rcs_remove, and rcs_rename. + my ($message, $user, $ipaddr)=@_; + + if (defined $user) { + $message="web commit by $user".(length $message ? ": $message" : ""); + } + elsif (defined $ipaddr) { + $message="web commit from $ipaddr".(length $message ? ": $message" : ""); + } + + if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) { + warn "cvs staged commit failed\n"; + return 1; # failure + } + return undef # success +} + +sub rcs_add ($) { + # filename is relative to the root of the srcdir + my $file=shift; + my $parent=IkiWiki::dirname($file); + my @files_to_add = ($file); + + eval q{use File::MimeInfo}; + error($@) if $@; + + until ((length($parent) == 0) || cvs_is_controlling("$config{srcdir}/$parent")){ + push @files_to_add, $parent; + $parent = IkiWiki::dirname($parent); + } + + while ($file = pop @files_to_add) { + if ((@files_to_add == 0) && + (File::MimeInfo::default $file ne 'text/plain')) { + # it's a binary file, add specially + cvs_runcvs(['add', '-kb', $file]) || + warn("cvs add $file failed\n"); + } else { + # directory or regular file + cvs_runcvs(['add', $file]) || + warn("cvs add $file failed\n"); + } + } +} + +sub rcs_remove ($) { + # filename is relative to the root of the srcdir + my $file=shift; + + return unless cvs_is_controlling; + + cvs_runcvs(['rm', '-f', $file]) || + warn("cvs rm $file failed\n"); +} + +sub rcs_rename ($$) { + # filenames relative to the root of the srcdir + my ($src, $dest)=@_; + + return unless cvs_is_controlling; + + chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!"); + + if (system("mv", "$src", "$dest") != 0) { + warn("filesystem rename failed\n"); + } + + rcs_add($dest); + rcs_remove($src); +} + +sub rcs_recentchanges($) { + my $num = shift; + my @ret; + + return unless cvs_is_controlling; + + eval q{use Date::Parse}; + error($@) if $@; + + chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!"); + + # There's no cvsps option to get the last N changesets. + # Write full output to a temp file and read backwards. + + eval q{use File::Temp qw/tempfile/}; + error($@) if $@; + eval q{use File::ReadBackwards}; + error($@) if $@; + + my (undef, $tmpfile) = tempfile(OPEN=>0); + system("env TZ=UTC cvsps -q --cvs-direct -z 30 -x >$tmpfile"); + if ($? == -1) { + error "couldn't run cvsps: $!\n"; + } elsif (($? >>8) != 0) { + error "cvsps exited " . ($? >> 8) . ": $!\n"; + } + + tie(*SPSVC, 'File::ReadBackwards', $tmpfile) + || error "couldn't open $tmpfile for read: $!\n"; + + while (my $line = ) { + $line =~ /^$/ || error "expected blank line, got $line"; + + my ($rev, $user, $committype, $when); + my (@message, @pages); + + # We're reading backwards. + # Forwards, an entry looks like so: + # --------------------- + # PatchSet $rev + # Date: $when + # Author: $user (or user CGI runs as, for web commits) + # Branch: branch + # Tag: tag + # Log: + # @message_lines + # Members: + # @pages (and revisions) + # + + while ($line = ) { + last if ($line =~ /^Members:/); + for ($line) { + s/^\s+//; + s/\s+$//; + } + my ($page, $revs) = split(/:/, $line); + my ($oldrev, $newrev) = split(/->/, $revs); + $oldrev =~ s/INITIAL/0/; + $newrev =~ s/\(DEAD\)//; + my $diffurl = defined $config{diffurl} ? $config{diffurl} : ""; + $diffurl=~s/\[\[file\]\]/$page/g; + $diffurl=~s/\[\[r1\]\]/$oldrev/g; + $diffurl=~s/\[\[r2\]\]/$newrev/g; + unshift @pages, { + page => pagename($page), + diffurl => $diffurl, + } if length $page; + } + + while ($line = ) { + last if ($line =~ /^Log:$/); + chomp $line; + unshift @message, { line => $line }; + } + $committype = "web"; + if (defined $message[0] && + $message[0]->{line}=~/$config{web_commit_regexp}/) { + $user=defined $2 ? "$2" : "$3"; + $message[0]->{line}=$4; + } else { + $committype="cvs"; + } + + $line = ; # Tag + $line = ; # Branch + + $line = ; + if ($line =~ /^Author: (.*)$/) { + $user = $1 unless defined $user && length $user; + } else { + error "expected Author, got $line"; + } + + $line = ; + if ($line =~ /^Date: (.*)$/) { + $when = str2time($1, 'UTC'); + } else { + error "expected Date, got $line"; + } + + $line = ; + if ($line =~ /^PatchSet (.*)$/) { + $rev = $1; + } else { + error "expected PatchSet, got $line"; + } + + $line = ; # --------------------- + + push @ret, { + rev => $rev, + user => $user, + committype => $committype, + when => $when, + message => [@message], + pages => [@pages], + } if @pages; + last if @ret >= $num; + } + + unlink($tmpfile) || error "couldn't unlink $tmpfile: $!\n"; + + return @ret; +} + +sub rcs_diff ($) { + my $rev=IkiWiki::possibly_foolish_untaint(int(shift)); + + chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!"); + + # diff output is unavoidably preceded by the cvsps PatchSet entry + my @cvsps = `env TZ=UTC cvsps -q --cvs-direct -z 30 -g -s $rev`; + my $blank_lines_seen = 0; + + while (my $line = shift @cvsps) { + $blank_lines_seen++ if ($line =~ /^$/); + last if $blank_lines_seen == 2; + } + + if (wantarray) { + return @cvsps; + } else { + return join("", @cvsps); + } +} + +sub rcs_getctime ($) { + my $file=shift; + + my $cvs_log_infoline=qr/^date: (.+);\s+author/; + + open CVSLOG, "cvs -Q log -r1.1 '$file' |" + || error "couldn't get cvs log output: $!\n"; + + my $date; + while () { + if (/$cvs_log_infoline/) { + $date=$1; + } + } + close CVSLOG || warn "cvs log $file exited $?"; + + if (! defined $date) { + warn "failed to parse cvs log for $file\n"; + return 0; + } + + eval q{use Date::Parse}; + error($@) if $@; + $date=str2time($date, 'UTC'); + debug("found ctime ".localtime($date)." for $file"); + return $date; +} + +1 diff --git a/IkiWiki/Plugin/rsync.pm b/IkiWiki/Plugin/rsync.pm new file mode 100644 index 000000000..3f049457b --- /dev/null +++ b/IkiWiki/Plugin/rsync.pm @@ -0,0 +1,45 @@ +#!/usr/bin/perl +package IkiWiki::Plugin::rsync; + +use warnings; +use strict; +use IkiWiki 3.00; + +sub import { + hook(type => "getsetup", id => "rsync", call => \&getsetup); + hook(type => "checkconfig", id => "rsync", call => \&checkconfig); + hook(type => "postrefresh", id => "rsync", call => \&postrefresh); +} + +sub getsetup () { + return + plugin => { + safe => 0, + rebuild => 0, + }, + rsync_command => { + type => "string", + example => "rsync -qa --delete /path/to/destdir/ user\@host:/path/to/docroot/", + description => "unattended command to upload regenerated pages", + safe => 0, + rebuild => 0, + }, +} + +sub checkconfig { + if (! exists $config{rsync_command} || + ! defined $config{rsync_command}) { + error("Must specify rsync_command"); + } +} + +sub postrefresh () { + system $config{rsync_command}; + if ($? == -1) { + error("failed to execute rsync_command: $!"); + } elsif ($? != 0) { + error(sprintf("rsync_command exited %d", $? >> 8)); + } +} + +1 diff --git a/IkiWiki/Plugin/unixauth.pm b/IkiWiki/Plugin/unixauth.pm new file mode 100644 index 000000000..4f0cb4dd2 --- /dev/null +++ b/IkiWiki/Plugin/unixauth.pm @@ -0,0 +1,195 @@ +#!/usr/bin/perl +# Ikiwiki unixauth authentication. +package IkiWiki::Plugin::unixauth; + +use warnings; +use strict; +use IkiWiki 2.00; + +sub import { + hook(type => "getsetup", id => "unixauth", call => \&getsetup); + hook(type => "formbuilder_setup", id => "unixauth", + call => \&formbuilder_setup); + hook(type => "formbuilder", id => "unixauth", + call => \&formbuilder); + hook(type => "sessioncgi", id => "unixauth", call => \&sessioncgi); +} + +sub getsetup () { + return + unixauth_type => { + type => "string", + example => "checkpassword", + description => "type of authenticator; can be 'checkpassword' or 'pwauth'", + safe => 0, + rebuild => 1, + }, + unixauth_command => { + type => "string", + example => "/path/to/checkpassword", + description => "full path and any arguments", + safe => 0, + rebuild => 1, + }, + unixauth_requiressl => { + type => "boolean", + example => "1", + description => "require SSL? strongly recommended", + safe => 0, + rebuild => 1, + }, + plugin => { + description => "Unix user authentication", + safe => 0, + rebuild => 1, + }, +} + +# Checks if a string matches a user's password, and returns true or false. +sub checkpassword ($$;$) { + my $user=shift; + my $password=shift; + my $field=shift || "password"; + + # It's very important that the user not be allowed to log in with + # an empty password! + if (! length $password) { + return 0; + } + + my $ret=0; + if (! exists $config{unixauth_type}) { + # admin needs to carefully think over his configuration + return 0; + } + elsif ($config{unixauth_type} eq "checkpassword") { + open UNIXAUTH, "|$config{unixauth_command} true 3<&0" or die("Could not run $config{unixauth_type}"); + print UNIXAUTH "$user\0$password\0Y123456\0"; + close UNIXAUTH; + $ret=!($?>>8); + } + elsif ($config{unixauth_type} eq "pwauth") { + open UNIXAUTH, "|$config{unixauth_command}" or die("Could not run $config{unixauth_type}"); + print UNIXAUTH "$user\n$password\n"; + close UNIXAUTH; + $ret=!($?>>8); + } + else { + # no such authentication type + return 0; + } + + if ($ret) { + my $userinfo=IkiWiki::userinfo_retrieve(); + if (! length $user || ! defined $userinfo || + ! exists $userinfo->{$user} || ! ref $userinfo->{$user}) { + IkiWiki::userinfo_setall($user, { + 'email' => '', + 'regdate' => time, + }); + } + } + + return $ret; +} + +sub formbuilder_setup (@) { + my %params=@_; + + my $form=$params{form}; + my $session=$params{session}; + my $cgi=$params{cgi}; + + # if not under SSL, die before even showing a login form, + # unless the admin explicitly says it's fine + if (! exists $config{unixauth_requiressl}) { + $config{unixauth_requiressl} = 1; + } + if ($config{unixauth_requiressl}) { + if ((! $config{sslcookie}) || (! exists $ENV{'HTTPS'})) { + die("SSL required to login. Contact your administrator.
"); + } + } + + if ($form->title eq "signin") { + $form->field(name => "name", required => 0); + $form->field(name => "password", type => "password", required => 0); + + if ($form->submitted) { + my $submittype=$form->submitted; + # Set required fields based on how form was submitted. + my %required=( + "Login" => [qw(name password)], + ); + foreach my $opt (@{$required{$submittype}}) { + $form->field(name => $opt, required => 1); + } + + # Validate password against name for Login. + if ($submittype eq "Login") { + $form->field( + name => "password", + validate => sub { + checkpassword($form->field("name"), shift); + }, + ); + } + + # XXX is this reachable? looks like no + elsif ($submittype eq "Login") { + $form->field( + name => "name", + validate => sub { + my $name=shift; + length $name && + IkiWiki::userinfo_get($name, "regdate"); + }, + ); + } + } + else { + # First time settings. + $form->field(name => "name"); + if ($session->param("name")) { + $form->field(name => "name", value => $session->param("name")); + } + } + } + elsif ($form->title eq "preferences") { + $form->field(name => "name", disabled => 1, + value => $session->param("name"), force => 1, + fieldset => "login"); + $form->field(name => "password", disabled => 1, type => "password", + fieldset => "login"), + } +} + +sub formbuilder (@) { + my %params=@_; + + my $form=$params{form}; + my $session=$params{session}; + my $cgi=$params{cgi}; + my $buttons=$params{buttons}; + + if ($form->title eq "signin") { + if ($form->submitted && $form->validate) { + if ($form->submitted eq 'Login') { + $session->param("name", $form->field("name")); + IkiWiki::cgi_postsignin($cgi, $session); + } + } + } + elsif ($form->title eq "preferences") { + if ($form->submitted eq "Save Preferences" && $form->validate) { + my $user_name=$form->field('name'); + } + } +} + +sub sessioncgi ($$) { + my $q=shift; + my $session=shift; +} + +1 diff --git a/IkiWiki/Render.pm b/IkiWiki/Render.pm index 246c2260d..5953b3fee 100644 --- a/IkiWiki/Render.pm +++ b/IkiWiki/Render.pm @@ -554,6 +554,7 @@ sub refresh () { if (%rendered) { run_hooks(change => sub { shift->(keys %rendered) }); } + run_hooks(postrefresh => sub { shift->() }); } sub commandline_render () { diff --git a/IkiWiki/Wrapper.pm b/IkiWiki/Wrapper.pm index 6555fe625..cc2223d7a 100644 --- a/IkiWiki/Wrapper.pm +++ b/IkiWiki/Wrapper.pm @@ -44,6 +44,18 @@ EOF $test_receive=IkiWiki::Receive::gen_wrapper(); } + my $check_cvs_add_dir=""; + if ($config{rcs} eq 'cvs') { + $check_cvs_add_dir=<<"EOF"; + { + int j; + for (j = 1; j < argc; j++) + if (strstr(argv[j], "New directory") != NULL) + exit(0); + } +EOF + } + my $check_commit_hook=""; my $pre_exec=""; if ($config{post_commit}) { @@ -119,6 +131,7 @@ addenv(char *var, char *val) { int main (int argc, char **argv) { char *s; +$check_cvs_add_dir $check_commit_hook $test_receive $envsave diff --git a/ikiwiki-makerepo b/ikiwiki-makerepo index 7f82e7177..aca8da153 100755 --- a/ikiwiki-makerepo +++ b/ikiwiki-makerepo @@ -6,7 +6,7 @@ srcdir="$2" repository="$3" usage () { - echo "usage: ikiwiki-makerepo svn|git|monotone|darcs srcdir repository" >&2 + echo "usage: ikiwiki-makerepo cvs|svn|git|monotone|darcs srcdir repository" >&2 echo " ikiwiki-makerepo bzr|mercurial srcdir" >&2 exit 1 } @@ -39,6 +39,30 @@ fi echo "Importing $srcdir into $rcs" case "$rcs" in +cvs) + if [ -e "$srcdir/CVS" ]; then + echo "$srcdir already seems to be a cvs working copy" >&2 + exit 1 + fi + cvs -Q -d "$repository" init + cd "$srcdir"/.. + cvs -Q -d "$repository" get -P CVSROOT + cd CVSROOT + echo .ikiwiki >> cvsignore + cvs -Q add cvsignore + echo "^ikiwiki $repository/CVSROOT/post-commit %{sVv} &" >> loginfo + cvs -Q commit -m "ikiwiki-makerepo setup" cvsignore loginfo + cd .. + rm -rf CVSROOT + cd "$srcdir" + cvs -Q -d "$repository" import -m "initial import" ikiwiki IKIWIKI PRE_CVS + cd .. + mv "$srcdir" "$srcdir.orig" + cvs -Q -d "$repository" get -P -d "$(basename "$srcdir")" ikiwiki + [ -d "$srcdir.orig/.ikiwiki" ] && mv "$srcdir.orig/.ikiwiki" "$srcdir" + rm -rf "$srcdir.orig" + echo "Directory $srcdir is now a checkout of $rcs repository $repository" +;; svn) if [ -e "$srcdir/.svn" ]; then echo "$srcdir already seems to be a svn working copy" >&2 diff --git a/t/file_pruned.t b/t/file_pruned.t index 7d46c4b7f..a05ad548f 100755 --- a/t/file_pruned.t +++ b/t/file_pruned.t @@ -1,7 +1,7 @@ #!/usr/bin/perl use warnings; use strict; -use Test::More tests => 24; +use Test::More tests => 27; BEGIN { use_ok("IkiWiki"); } @@ -9,6 +9,9 @@ BEGIN { use_ok("IkiWiki"); } ok(IkiWiki::file_pruned("src/.ikiwiki/", "src")); ok(IkiWiki::file_pruned("src/.ikiwiki/index", "src")); +ok(IkiWiki::file_pruned("src/CVS", "src")); +ok(IkiWiki::file_pruned("src/subdir/CVS", "src")); +ok(IkiWiki::file_pruned("src/subdir/CVS/foo", "src")); ok(IkiWiki::file_pruned("src/.svn", "src")); ok(IkiWiki::file_pruned("src/subdir/.svn", "src")); ok(IkiWiki::file_pruned("src/subdir/.svn/foo", "src"));