diff options
| author | Junio C Hamano <gitster@pobox.com> | 2013-01-23 21:16:38 -0800 | 
|---|---|---|
| committer | Junio C Hamano <gitster@pobox.com> | 2013-01-23 21:16:38 -0800 | 
| commit | 6a3d05da55a488a4f5b2c3b2091f4445fa3847e9 (patch) | |
| tree | 65fbe4f7eb5ef3dc8e0dad87955b0fda339cb20b | |
| parent | f55cb042bd70d0582341ded76f18c74c770b2ff3 (diff) | |
| parent | 828eff76b058c21912e1c6dac33dde1b5d9f913a (diff) | |
| download | git-6a3d05da55a488a4f5b2c3b2091f4445fa3847e9.tar.gz | |
Merge branch 'mo/cvs-server-updates'
Various git-cvsserver updates.
* mo/cvs-server-updates:
  t9402: Use TABs for indentation
  t9402: Rename check.cvsCount and check.list
  t9402: Simplify git ls-tree
  t9402: Add missing &&; Code style
  t9402: No space after IO-redirection
  t9402: Dont use test_must_fail cvs
  t9402: improve check_end_tree() and check_end_full_tree()
  t9402: sed -i is not portable
  cvsserver Documentation: new cvs ... -r support
  cvsserver: add t9402 to test branch and tag refs
  cvsserver: support -r and sticky tags for most operations
  cvsserver: Add version awareness to argsfromdir
  cvsserver: generalize getmeta() to recognize commit refs
  cvsserver: implement req_Sticky and related utilities
  cvsserver: add misc commit lookup, file meta data, and file listing functions
  cvsserver: define a tag name character escape mechanism
  cvsserver: cleanup extra slashes in filename arguments
  cvsserver: factor out git-log parsing logic
| -rw-r--r-- | Documentation/git-cvsserver.txt | 37 | ||||
| -rwxr-xr-x | git-cvsserver.perl | 1927 | ||||
| -rwxr-xr-x | t/t9402-git-cvsserver-refs.sh | 551 | 
3 files changed, 2198 insertions, 317 deletions
diff --git a/Documentation/git-cvsserver.txt b/Documentation/git-cvsserver.txt index 88d814af0e..940c2ba66a 100644 --- a/Documentation/git-cvsserver.txt +++ b/Documentation/git-cvsserver.txt @@ -359,6 +359,43 @@ Operations supported  All the operations required for normal use are supported, including  checkout, diff, status, update, log, add, remove, commit. + +Most CVS command arguments that read CVS tags or revision numbers +(typically -r) work, and also support any git refspec +(tag, branch, commit ID, etc). +However, CVS revision numbers for non-default branches are not well +emulated, and cvs log does not show tags or branches at +all.  (Non-main-branch CVS revision numbers superficially resemble CVS +revision numbers, but they actually encode a git commit ID directly, +rather than represent the number of revisions since the branch point.) + +Note that there are two ways to checkout a particular branch. +As described elsewhere on this page, the "module" parameter +of cvs checkout is interpreted as a branch name, and it becomes +the main branch.  It remains the main branch for a given sandbox +even if you temporarily make another branch sticky with +cvs update -r.  Alternatively, the -r argument can indicate +some other branch to actually checkout, even though the module +is still the "main" branch.  Tradeoffs (as currently +implemented): Each new "module" creates a new database on disk with +a history for the given module, and after the database is created, +operations against that main branch are fast.  Or alternatively, +-r doesn't take any extra disk space, but may be significantly slower for +many operations, like cvs update. + +If you want to refer to a git refspec that has characters that are +not allowed by CVS, you have two options.  First, it may just work +to supply the git refspec directly to the appropriate CVS -r argument; +some CVS clients don't seem to do much sanity checking of the argument. +Second, if that fails, you can use a special character escape mechanism +that only uses characters that are valid in CVS tags.  A sequence +of 4 or 5 characters of the form (underscore (`"_"`), dash (`"-"`), +one or two characters, and dash (`"-"`)) can encode various characters based +on the one or two letters: `"s"` for slash (`"/"`), `"p"` for +period (`"."`), `"u"` for underscore (`"_"`), or two hexadecimal digits +for any byte value at all (typically an ASCII number, or perhaps a part +of a UTF-8 encoded character). +  Legacy monitoring operations are not supported (edit, watch and related).  Exports and tagging (tags and branches) are not supported at this stage. diff --git a/git-cvsserver.perl b/git-cvsserver.perl index c5ebfa0636..3679074983 100755 --- a/git-cvsserver.perl +++ b/git-cvsserver.perl @@ -60,6 +60,7 @@ my $methods = {      'Valid-responses' => \&req_Validresponses,      'valid-requests'  => \&req_validrequests,      'Directory'       => \&req_Directory, +    'Sticky'          => \&req_Sticky,      'Entry'           => \&req_Entry,      'Modified'        => \&req_Modified,      'Unchanged'       => \&req_Unchanged, @@ -470,11 +471,19 @@ sub req_Directory      {          $log->info("Setting prepend to '$state->{path}'");          $state->{prependdir} = $state->{path}; +        my %entries;          foreach my $entry ( keys %{$state->{entries}} )          { -            $state->{entries}{$state->{prependdir} . $entry} = $state->{entries}{$entry}; -            delete $state->{entries}{$entry}; +            $entries{$state->{prependdir} . $entry} = $state->{entries}{$entry};          } +        $state->{entries}=\%entries; + +        my %dirMap; +        foreach my $dir ( keys %{$state->{dirMap}} ) +        { +            $dirMap{$state->{prependdir} . $dir} = $state->{dirMap}{$dir}; +        } +        $state->{dirMap}=\%dirMap;      }      if ( defined ( $state->{prependdir} ) ) @@ -482,9 +491,60 @@ sub req_Directory          $log->debug("Prepending '$state->{prependdir}' to state|directory");          $state->{directory} = $state->{prependdir} . $state->{directory}      } + +    if ( ! defined($state->{dirMap}{$state->{directory}}) ) +    { +        $state->{dirMap}{$state->{directory}} = +            { +                'names' => {} +                #'tagspec' => undef +            }; +    } +      $log->debug("req_Directory : localdir=$data repository=$repository path=$state->{path} directory=$state->{directory} module=$state->{module}");  } +# Sticky tagspec \n +#     Response expected: no. Tell the server that the directory most +#     recently specified with Directory has a sticky tag or date +#     tagspec. The first character of tagspec is T for a tag, D for +#     a date, or some other character supplied by a Set-sticky +#     response from a previous request to the server. The remainder +#     of tagspec contains the actual tag or date, again as supplied +#     by Set-sticky. +#          The server should remember Static-directory and Sticky requests +#     for a particular directory; the client need not resend them each +#     time it sends a Directory request for a given directory. However, +#     the server is not obliged to remember them beyond the context +#     of a single command. +sub req_Sticky +{ +    my ( $cmd, $tagspec ) = @_; + +    my ( $stickyInfo ); +    if($tagspec eq "") +    { +        # nothing +    } +    elsif($tagspec=~/^T([^ ]+)\s*$/) +    { +        $stickyInfo = { 'tag' => $1 }; +    } +    elsif($tagspec=~/^D([0-9.]+)\s*$/) +    { +        $stickyInfo= { 'date' => $1 }; +    } +    else +    { +        die "Unknown tag_or_date format\n"; +    } +    $state->{dirMap}{$state->{directory}}{stickyInfo}=$stickyInfo; + +    $log->debug("req_Sticky : tagspec=$tagspec repository=$state->{repository}" +                . " path=$state->{path} directory=$state->{directory}" +                . " module=$state->{module}"); +} +  # Entry entry-line \n  #     Response expected: no. Tell the server what version of a file is on the  #     local machine. The name in entry-line is a name relative to the directory @@ -511,6 +571,8 @@ sub req_Entry          tag_or_date => $data[5],      }; +    $state->{dirMap}{$state->{directory}}{names}{$data[1]} = 'F'; +      $log->info("Received entry line '$data' => '" . $state->{directory} . $data[1] . "'");  } @@ -549,7 +611,10 @@ sub req_add      {          $filename = filecleanup($filename); -        my $meta = $updater->getmeta($filename); +        # no -r, -A, or -D with add +        my $stickyInfo = resolveStickyInfo($filename); + +        my $meta = $updater->getmeta($filename,$stickyInfo);          my $wrev = revparse($filename);          if ($wrev && $meta && ($wrev=~/^-/)) @@ -572,8 +637,10 @@ sub req_add                  # this is an "entries" line                  my $kopts = kopts_from_path($filename,"sha1",$meta->{filehash}); -                $log->debug("/$filepart/$meta->{revision}//$kopts/"); -                print "/$filepart/$meta->{revision}//$kopts/\n"; +                my $entryLine = "/$filepart/$meta->{revision}//$kopts/"; +                $entryLine .= getStickyTagOrDate($stickyInfo); +                $log->debug($entryLine); +                print "$entryLine\n";                  # permissions                  $log->debug("SEND : u=$meta->{mode},g=$meta->{mode},o=$meta->{mode}");                  print "u=$meta->{mode},g=$meta->{mode},o=$meta->{mode}\n"; @@ -604,7 +671,8 @@ sub req_add          print "$filename\n";          my $kopts = kopts_from_path($filename,"file",                          $state->{entries}{$filename}{modified_filename}); -        print "/$filepart/0//$kopts/\n"; +        print "/$filepart/0//$kopts/" . +              getStickyTagOrDate($stickyInfo) . "\n";          my $requestedKopts = $state->{opt}{k};          if(defined($requestedKopts)) @@ -672,7 +740,10 @@ sub req_remove              next;          } -        my $meta = $updater->getmeta($filename); +        # only from entries +        my $stickyInfo = resolveStickyInfo($filename); + +        my $meta = $updater->getmeta($filename,$stickyInfo);          my $wrev = revparse($filename);          unless ( defined ( $wrev ) ) @@ -702,7 +773,7 @@ sub req_remove          print "Checked-in $dirpart\n";          print "$filename\n";          my $kopts = kopts_from_path($filename,"sha1",$meta->{filehash}); -        print "/$filepart/-$wrev//$kopts/\n"; +        print "/$filepart/-$wrev//$kopts/" . getStickyTagOrDate($stickyInfo) . "\n";          $rmcount++;      } @@ -882,6 +953,9 @@ sub req_co          return 1;      } +    my $stickyInfo = { 'tag' => $state->{opt}{r}, +                       'date' => $state->{opt}{D} }; +      my $module = $state->{args}[0];      $state->{module} = $module;      my $checkout_path = $module; @@ -899,64 +973,32 @@ sub req_co      my $updater = GITCVS::updater->new($state->{CVSROOT}, $module, $log);      $updater->update(); -    $checkout_path =~ s|/$||; # get rid of trailing slashes +    my $headHash; +    if( defined($stickyInfo) && defined($stickyInfo->{tag}) ) +    { +        $headHash = $updater->lookupCommitRef($stickyInfo->{tag}); +        if( !defined($headHash) ) +        { +            print "error 1 no such tag `$stickyInfo->{tag}'\n"; +            cleanupWorkTree(); +            exit; +        } +    } -    # Eclipse seems to need the Clear-sticky command -    # to prepare the 'Entries' file for the new directory. -    print "Clear-sticky $checkout_path/\n"; -    print $state->{CVSROOT} . "/$module/\n"; -    print "Clear-static-directory $checkout_path/\n"; -    print $state->{CVSROOT} . "/$module/\n"; -    print "Clear-sticky $checkout_path/\n"; # yes, twice -    print $state->{CVSROOT} . "/$module/\n"; -    print "Template $checkout_path/\n"; -    print $state->{CVSROOT} . "/$module/\n"; -    print "0\n"; - -    # instruct the client that we're checking out to $checkout_path -    print "E cvs checkout: Updating $checkout_path\n"; +    $checkout_path =~ s|/$||; # get rid of trailing slashes      my %seendirs = ();      my $lastdir =''; -    # recursive -    sub prepdir { -       my ($dir, $repodir, $remotedir, $seendirs) = @_; -       my $parent = dirname($dir); -       $dir       =~ s|/+$||; -       $repodir   =~ s|/+$||; -       $remotedir =~ s|/+$||; -       $parent    =~ s|/+$||; -       $log->debug("announcedir $dir, $repodir, $remotedir" ); - -       if ($parent eq '.' || $parent eq './') { -           $parent = ''; -       } -       # recurse to announce unseen parents first -       if (length($parent) && !exists($seendirs->{$parent})) { -           prepdir($parent, $repodir, $remotedir, $seendirs); -       } -       # Announce that we are going to modify at the parent level -       if ($parent) { -           print "E cvs checkout: Updating $remotedir/$parent\n"; -       } else { -           print "E cvs checkout: Updating $remotedir\n"; -       } -       print "Clear-sticky $remotedir/$parent/\n"; -       print "$repodir/$parent/\n"; - -       print "Clear-static-directory $remotedir/$dir/\n"; -       print "$repodir/$dir/\n"; -       print "Clear-sticky $remotedir/$parent/\n"; # yes, twice -       print "$repodir/$parent/\n"; -       print "Template $remotedir/$dir/\n"; -       print "$repodir/$dir/\n"; -       print "0\n"; - -       $seendirs->{$dir} = 1; -    } - -    foreach my $git ( @{$updater->gethead} ) +    prepDirForOutput( +            ".", +            $state->{CVSROOT} . "/$module", +            $checkout_path, +            \%seendirs, +            'checkout', +            $state->{dirArgs} ); + +    foreach my $git ( @{$updater->getAnyHead($headHash)} )      {          # Don't want to check out deleted files          next if ( $git->{filehash} eq "deleted" ); @@ -964,16 +1006,13 @@ sub req_co          my $fullName = $git->{name};          ( $git->{name}, $git->{dir} ) = filenamesplit($git->{name}); -       if (length($git->{dir}) && $git->{dir} ne './' -           && $git->{dir} ne $lastdir ) { -           unless (exists($seendirs{$git->{dir}})) { -               prepdir($git->{dir}, $state->{CVSROOT} . "/$module/", -                       $checkout_path, \%seendirs); -               $lastdir = $git->{dir}; -               $seendirs{$git->{dir}} = 1; -           } -           print "E cvs checkout: Updating /$checkout_path/$git->{dir}\n"; -       } +        unless (exists($seendirs{$git->{dir}})) { +            prepDirForOutput($git->{dir}, $state->{CVSROOT} . "/$module/", +                             $checkout_path, \%seendirs, 'checkout', +                             $state->{dirArgs} ); +            $lastdir = $git->{dir}; +            $seendirs{$git->{dir}} = 1; +        }          # modification time of this file          print "Mod-time $git->{modified}\n"; @@ -993,7 +1032,8 @@ sub req_co          # this is an "entries" line          my $kopts = kopts_from_path($fullName,"sha1",$git->{filehash}); -        print "/$git->{name}/$git->{revision}//$kopts/\n"; +        print "/$git->{name}/$git->{revision}//$kopts/" . +                        getStickyTagOrDate($stickyInfo) . "\n";          # permissions          print "u=$git->{mode},g=$git->{mode},o=$git->{mode}\n"; @@ -1006,6 +1046,119 @@ sub req_co      statecleanup();  } +# used by req_co and req_update to set up directories for files +# recursively handles parents +sub prepDirForOutput +{ +    my ($dir, $repodir, $remotedir, $seendirs, $request, $dirArgs) = @_; + +    my $parent = dirname($dir); +    $dir       =~ s|/+$||; +    $repodir   =~ s|/+$||; +    $remotedir =~ s|/+$||; +    $parent    =~ s|/+$||; + +    if ($parent eq '.' || $parent eq './') +    { +        $parent = ''; +    } +    # recurse to announce unseen parents first +    if( length($parent) && +        !exists($seendirs->{$parent}) && +        ( $request eq "checkout" || +          exists($dirArgs->{$parent}) ) ) +    { +        prepDirForOutput($parent, $repodir, $remotedir, +                         $seendirs, $request, $dirArgs); +    } +    # Announce that we are going to modify at the parent level +    if ($dir eq '.' || $dir eq './') +    { +        $dir = ''; +    } +    if(exists($seendirs->{$dir})) +    { +        return; +    } +    $log->debug("announcedir $dir, $repodir, $remotedir" ); +    my($thisRemoteDir,$thisRepoDir); +    if ($dir ne "") +    { +        $thisRepoDir="$repodir/$dir"; +        if($remotedir eq ".") +        { +            $thisRemoteDir=$dir; +        } +        else +        { +            $thisRemoteDir="$remotedir/$dir"; +        } +    } +    else +    { +        $thisRepoDir=$repodir; +        $thisRemoteDir=$remotedir; +    } +    unless ( $state->{globaloptions}{-Q} || $state->{globaloptions}{-q} ) +    { +        print "E cvs $request: Updating $thisRemoteDir\n"; +    } + +    my ($opt_r)=$state->{opt}{r}; +    my $stickyInfo; +    if(exists($state->{opt}{A})) +    { +        # $stickyInfo=undef; +    } +    elsif( defined($opt_r) && $opt_r ne "" ) +           # || ( defined($state->{opt}{D}) && $state->{opt}{D} ne "" ) # TODO +    { +        $stickyInfo={ 'tag' => (defined($opt_r)?$opt_r:undef) }; + +        # TODO: Convert -D value into the form 2011.04.10.04.46.57, +        #   similar to an entry line's sticky date, without the D prefix. +        #   It sometimes (always?) arrives as something more like +        #   '10 Apr 2011 04:46:57 -0000'... +        # $stickyInfo={ 'date' => (defined($stickyDate)?$stickyDate:undef) }; +    } +    else +    { +        $stickyInfo=getDirStickyInfo($state->{prependdir} . $dir); +    } + +    my $stickyResponse; +    if(defined($stickyInfo)) +    { +        $stickyResponse = "Set-sticky $thisRemoteDir/\n" . +                          "$thisRepoDir/\n" . +                          getStickyTagOrDate($stickyInfo) . "\n"; +    } +    else +    { +        $stickyResponse = "Clear-sticky $thisRemoteDir/\n" . +                          "$thisRepoDir/\n"; +    } + +    unless ( $state->{globaloptions}{-n} ) +    { +        print $stickyResponse; + +        print "Clear-static-directory $thisRemoteDir/\n"; +        print "$thisRepoDir/\n"; +        print $stickyResponse; # yes, twice +        print "Template $thisRemoteDir/\n"; +        print "$thisRepoDir/\n"; +        print "0\n"; +    } + +    $seendirs->{$dir} = 1; + +    # FUTURE: This would more accurately emulate CVS by sending +    #   another copy of sticky after processing the files in that +    #   directory.  Or intermediate: perhaps send all sticky's for +    #   $seendirs after after processing all files. +} +  # update \n  #     Response expected: yes. Actually do a cvs update command. This uses any  #     previous Argument, Directory, Entry, or Modified requests, if they have @@ -1049,29 +1202,19 @@ sub req_update      #$log->debug("update state : " . Dumper($state)); -    my $last_dirname = "///"; +    my($repoDir); +    $repoDir=$state->{CVSROOT} . "/$state->{module}/$state->{prependdir}"; + +    my %seendirs = ();      # foreach file specified on the command line ... -    foreach my $filename ( @{$state->{args}} ) +    foreach my $argsFilename ( @{$state->{args}} )      { -        $filename = filecleanup($filename); +        my $filename; +        $filename = filecleanup($argsFilename);          $log->debug("Processing file $filename"); -        unless ( $state->{globaloptions}{-Q} || $state->{globaloptions}{-q} ) -        { -            my $cur_dirname = dirname($filename); -            if ( $cur_dirname ne $last_dirname ) -            { -                $last_dirname = $cur_dirname; -                if ( $cur_dirname eq "" ) -                { -                    $cur_dirname = "."; -                } -                print "E cvs update: Updating $cur_dirname\n"; -            } -        } -          # if we have a -C we should pretend we never saw modified stuff          if ( exists ( $state->{opt}{C} ) )          { @@ -1080,13 +1223,11 @@ sub req_update              $state->{entries}{$filename}{unchanged} = 1;          } -        my $meta; -        if ( defined($state->{opt}{r}) and $state->{opt}{r} =~ /^(1\.\d+)$/ ) -        { -            $meta = $updater->getmeta($filename, $1); -        } else { -            $meta = $updater->getmeta($filename); -        } +        my $stickyInfo = resolveStickyInfo($filename, +                                           $state->{opt}{r}, +                                           $state->{opt}{D}, +                                           exists($state->{opt}{A})); +        my $meta = $updater->getmeta($filename, $stickyInfo);          # If -p was given, "print" the contents of the requested revision.          if ( exists ( $state->{opt}{p} ) ) { @@ -1099,6 +1240,17 @@ sub req_update              next;          } +        # Directories: +        prepDirForOutput( +                dirname($argsFilename), +                $repoDir, +                ".", +                \%seendirs, +                "update", +                $state->{dirArgs} ); + +        my $wrev = revparse($filename); +  	if ( ! defined $meta )  	{  	    $meta = { @@ -1106,16 +1258,23 @@ sub req_update  	        revision => '0',  	        filehash => 'added'  	    }; +	    if($wrev ne "0") +	    { +	        $meta->{filehash}='deleted'; +	    }  	}          my $oldmeta = $meta; -        my $wrev = revparse($filename); -          # If the working copy is an old revision, lets get that version too for comparison. -        if ( defined($wrev) and $wrev ne $meta->{revision} ) +        my $oldWrev=$wrev; +        if(defined($oldWrev))          { -            $oldmeta = $updater->getmeta($filename, $wrev); +            $oldWrev=~s/^-//; +            if($oldWrev ne $meta->{revision}) +            { +                $oldmeta = $updater->getmeta($filename, $oldWrev); +            }          }          #$log->debug("Target revision is $meta->{revision}, current working revision is $wrev"); @@ -1133,6 +1292,7 @@ sub req_update          if ( defined ( $wrev )               and defined($meta->{revision})               and $wrev eq $meta->{revision} +             and $wrev ne "0"               and defined($state->{entries}{$filename}{modified_hash})               and not exists ( $state->{opt}{C} ) )          { @@ -1143,7 +1303,7 @@ sub req_update              next;          } -        if ( $meta->{filehash} eq "deleted" ) +        if ( $meta->{filehash} eq "deleted" && $wrev ne "0" )          {              # TODO: If it has been modified in the sandbox, error out              #   with the appropriate message, rather than deleting a modified @@ -1205,10 +1365,6 @@ sub req_update  		    $log->debug("Updating existing file 'Update-existing $dirpart'");  		} else {  		    # instruct client we're sending a file to put in this path as a new file -		    print "Clear-static-directory $dirpart\n"; -		    print $state->{CVSROOT} . "/$state->{module}/$dirpart\n"; -		    print "Clear-sticky $dirpart\n"; -		    print $state->{CVSROOT} . "/$state->{module}/$dirpart\n";  		    $log->debug("Creating new file 'Created $dirpart'");  		    print "Created $dirpart\n"; @@ -1217,8 +1373,10 @@ sub req_update  		# this is an "entries" line  		my $kopts = kopts_from_path($filename,"sha1",$meta->{filehash}); -		$log->debug("/$filepart/$meta->{revision}//$kopts/"); -		print "/$filepart/$meta->{revision}//$kopts/\n"; +                my $entriesLine = "/$filepart/$meta->{revision}//$kopts/"; +                $entriesLine .= getStickyTagOrDate($stickyInfo); +		$log->debug($entriesLine); +		print "$entriesLine\n";  		# permissions  		$log->debug("SEND : u=$meta->{mode},g=$meta->{mode},o=$meta->{mode}"); @@ -1266,7 +1424,9 @@ sub req_update                      my $kopts = kopts_from_path("$dirpart/$filepart",                                                  "file",$mergedFile);                      $log->debug("/$filepart/$meta->{revision}//$kopts/"); -                    print "/$filepart/$meta->{revision}//$kopts/\n"; +                    my $entriesLine="/$filepart/$meta->{revision}//$kopts/"; +                    $entriesLine .= getStickyTagOrDate($stickyInfo); +                    print "$entriesLine\n";                  }              }              elsif ( $return == 1 ) @@ -1282,7 +1442,9 @@ sub req_update                      print $state->{CVSROOT} . "/$state->{module}/$filename\n";                      my $kopts = kopts_from_path("$dirpart/$filepart",                                                  "file",$mergedFile); -                    print "/$filepart/$meta->{revision}/+/$kopts/\n"; +                    my $entriesLine = "/$filepart/$meta->{revision}/+/$kopts/"; +                    $entriesLine .= getStickyTagOrDate($stickyInfo); +                    print "$entriesLine\n";                  }              }              else @@ -1310,6 +1472,43 @@ sub req_update      } +    # prepDirForOutput() any other existing directories unless they already +    # have the right sticky tag: +    unless ( $state->{globaloptions}{n} ) +    { +        my $dir; +        foreach $dir (keys(%{$state->{dirMap}})) +        { +            if( ! $seendirs{$dir} && +                exists($state->{dirArgs}{$dir}) ) +            { +                my($oldTag); +                $oldTag=$state->{dirMap}{$dir}{tagspec}; + +                unless( ( exists($state->{opt}{A}) && +                          defined($oldTag) ) || +                          ( defined($state->{opt}{r}) && +                            ( !defined($oldTag) || +                              $state->{opt}{r} ne $oldTag ) ) ) +                        # TODO?: OR sticky dir is different... +                { +                    next; +                } + +                prepDirForOutput( +                        $dir, +                        $repoDir, +                        ".", +                        \%seendirs, +                        'update', +                        $state->{dirArgs} ); +            } + +            # TODO?: Consider sending a final duplicate Sticky response +            #   to more closely mimic real CVS. +        } +    } +      print "ok\n";  } @@ -1342,23 +1541,11 @@ sub req_ci      my $updater = GITCVS::updater->new($state->{CVSROOT}, $state->{module}, $log);      $updater->update(); -    # Remember where the head was at the beginning. -    my $parenthash = `git show-ref -s refs/heads/$state->{module}`; -    chomp $parenthash; -    if ($parenthash !~ /^[0-9a-f]{40}$/) { -	    print "error 1 pserver cannot find the current HEAD of module"; -	    cleanupWorkTree(); -	    exit; -    } - -    setupWorkTree($parenthash); - -    $log->info("Lockless commit start, basing commit on '$work->{workDir}', index file is '$work->{index}'"); - -    $log->info("Created index '$work->{index}' for head $state->{module} - exit status $?"); -      my @committedfiles = ();      my %oldmeta; +    my $stickyInfo; +    my $branchRef; +    my $parenthash;      # foreach file specified on the command line ...      foreach my $filename ( @{$state->{args}} ) @@ -1368,7 +1555,67 @@ sub req_ci          next unless ( exists $state->{entries}{$filename}{modified_filename} or not $state->{entries}{$filename}{unchanged} ); -        my $meta = $updater->getmeta($filename); +        ##### +        # Figure out which branch and parenthash we are committing +        # to, and setup worktree: + +        # should always come from entries: +        my $fileStickyInfo = resolveStickyInfo($filename); +        if( !defined($branchRef) ) +        { +            $stickyInfo = $fileStickyInfo; +            if( defined($stickyInfo) && +                ( defined($stickyInfo->{date}) || +                  !defined($stickyInfo->{tag}) ) ) +            { +                print "error 1 cannot commit with sticky date for file `$filename'\n"; +                cleanupWorkTree(); +                exit; +            } + +            $branchRef = "refs/heads/$state->{module}"; +            if ( defined($stickyInfo) && defined($stickyInfo->{tag}) ) +            { +                $branchRef = "refs/heads/$stickyInfo->{tag}"; +            } + +            $parenthash = `git show-ref -s $branchRef`; +            chomp $parenthash; +            if ($parenthash !~ /^[0-9a-f]{40}$/) +            { +                if ( defined($stickyInfo) && defined($stickyInfo->{tag}) ) +                { +                    print "error 1 sticky tag `$stickyInfo->{tag}' for file `$filename' is not a branch\n"; +                } +                else +                { +                    print "error 1 pserver cannot find the current HEAD of module"; +                } +                cleanupWorkTree(); +                exit; +            } + +            setupWorkTree($parenthash); + +            $log->info("Lockless commit start, basing commit on '$work->{workDir}', index file is '$work->{index}'"); + +            $log->info("Created index '$work->{index}' for head $state->{module} - exit status $?"); +        } +        elsif( !refHashEqual($stickyInfo,$fileStickyInfo) ) +        { +            #TODO: We could split the cvs commit into multiple +            #  git commits by distinct stickyTag values, but that +            #  is lowish priority. +            print "error 1 Committing different files to different" +                  . " branches is not currently supported\n"; +            cleanupWorkTree(); +            exit; +        } + +        ##### +        # Process this file: + +        my $meta = $updater->getmeta($filename,$stickyInfo);  	$oldmeta{$filename} = $meta;          my $wrev = revparse($filename); @@ -1470,7 +1717,7 @@ sub req_ci      }  	### Emulate git-receive-pack by running hooks/update -	my @hook = ( $ENV{GIT_DIR}.'hooks/update', "refs/heads/$state->{module}", +	my @hook = ( $ENV{GIT_DIR}.'hooks/update', $branchRef,  			$parenthash, $commithash );  	if( -x $hook[0] ) {  		unless( system( @hook ) == 0 ) @@ -1484,7 +1731,7 @@ sub req_ci  	### Update the ref  	if (system(qw(git update-ref -m), "cvsserver ci", -			"refs/heads/$state->{module}", $commithash, $parenthash)) { +			$branchRef, $commithash, $parenthash)) {  		$log->warn("update-ref for $state->{module} failed.");  		print "error 1 Cannot commit -- update first\n";  		cleanupWorkTree(); @@ -1498,7 +1745,7 @@ sub req_ci  		local $SIG{PIPE} = sub { die 'pipe broke' }; -		print $pipe "$parenthash $commithash refs/heads/$state->{module}\n"; +		print $pipe "$parenthash $commithash $branchRef\n";  		close $pipe || die "bad pipe: $! $?";  	} @@ -1508,7 +1755,7 @@ sub req_ci  	### Then hooks/post-update  	$hook = $ENV{GIT_DIR}.'hooks/post-update';  	if (-x $hook) { -		system($hook, "refs/heads/$state->{module}"); +		system($hook, $branchRef);  	}      # foreach file specified on the command line ... @@ -1516,7 +1763,7 @@ sub req_ci      {          $filename = filecleanup($filename); -        my $meta = $updater->getmeta($filename); +        my $meta = $updater->getmeta($filename,$stickyInfo);  	unless (defined $meta->{revision}) {  	  $meta->{revision} = "1.1";  	} @@ -1540,7 +1787,8 @@ sub req_ci              print "Checked-in $dirpart\n";              print "$filename\n";              my $kopts = kopts_from_path($filename,"sha1",$meta->{filehash}); -            print "/$filepart/$meta->{revision}//$kopts/\n"; +            print "/$filepart/$meta->{revision}//$kopts/" . +                  getStickyTagOrDate($stickyInfo) . "\n";          }      } @@ -1577,16 +1825,19 @@ sub req_status             next;          } -        my $meta = $updater->getmeta($filename); -        my $oldmeta = $meta; -          my $wrev = revparse($filename); +        my $stickyInfo = resolveStickyInfo($filename); +        my $meta = $updater->getmeta($filename,$stickyInfo); +        my $oldmeta = $meta; +          # If the working copy is an old revision, lets get that          # version too for comparison.          if ( defined($wrev) and $wrev ne $meta->{revision} )          { -            $oldmeta = $updater->getmeta($filename, $wrev); +            my($rmRev)=$wrev; +            $rmRev=~s/^-//; +            $oldmeta = $updater->getmeta($filename, $rmRev);          }          # TODO : All possible statuses aren't yet implemented @@ -1629,6 +1880,7 @@ sub req_status          # same revision but there are local changes          if ( defined ( $wrev ) and defined($meta->{revision}) and               $wrev eq $meta->{revision} and +             $wrev ne "0" and               $state->{entries}{$filename}{modified_filename} )          {              $status ||= "Locally Modified"; @@ -1644,7 +1896,8 @@ sub req_status          }          if ( defined ( $state->{entries}{$filename}{revision} ) and -             not defined ( $meta->{revision} ) ) +             ( !defined($meta->{revision}) || +               $meta->{revision} eq "0" ) )          {              $status ||= "Locally Added";          } @@ -1740,98 +1993,133 @@ sub req_diff      # be providing status on ...      argsfromdir($updater); +    my($foundDiff); +      # foreach file specified on the command line ... -    foreach my $filename ( @{$state->{args}} ) +    foreach my $argFilename ( @{$state->{args}} )      { -        $filename = filecleanup($filename); +        my($filename) = filecleanup($argFilename);          my ( $fh, $file1, $file2, $meta1, $meta2, $filediff );          my $wrev = revparse($filename); -        # We need _something_ to diff against -        next unless ( defined ( $wrev ) ); +        # Priority for revision1: +        #  1. First -r (missing file: check -N) +        #  2. wrev from client's Entry line +        #      - missing line/file: check -N +        #      - "0": added file not committed (empty contents for rev1) +        #      - Prefixed with dash (to be removed): check -N -        # if we have a -r switch, use it          if ( defined ( $revision1 ) )          { -            ( undef, $file1 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 );              $meta1 = $updater->getmeta($filename, $revision1); -            unless ( defined ( $meta1 ) and $meta1->{filehash} ne "deleted" ) +        } +        elsif( defined($wrev) && $wrev ne "0" ) +        { +            my($rmRev)=$wrev; +            $rmRev=~s/^-//; +            $meta1 = $updater->getmeta($filename, $rmRev); +        } +        if ( !defined($meta1) || +             $meta1->{filehash} eq "deleted" ) +        { +            if( !exists($state->{opt}{N}) )              { -                print "E File $filename at revision $revision1 doesn't exist\n"; +                if(!defined($revision1)) +                { +                    print "E File $filename at revision $revision1 doesn't exist\n"; +                }                  next;              } -            transmitfile($meta1->{filehash}, { targetfile => $file1 }); -        } -        # otherwise we just use the working copy revision -        else -        { -            ( undef, $file1 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 ); -            $meta1 = $updater->getmeta($filename, $wrev); -            transmitfile($meta1->{filehash}, { targetfile => $file1 }); +            elsif( !defined($meta1) ) +            { +                $meta1 = { +                    name => $filename, +                    revision => '0', +                    filehash => 'deleted' +                }; +            }          } +        # Priority for revision2: +        #  1. Second -r (missing file: check -N) +        #  2. Modified file contents from client +        #  3. wrev from client's Entry line +        #      - missing line/file: check -N +        #      - Prefixed with dash (to be removed): check -N +          # if we have a second -r switch, use it too          if ( defined ( $revision2 ) )          { -            ( undef, $file2 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 );              $meta2 = $updater->getmeta($filename, $revision2); - -            unless ( defined ( $meta2 ) and $meta2->{filehash} ne "deleted" ) -            { -                print "E File $filename at revision $revision2 doesn't exist\n"; -                next; -            } - -            transmitfile($meta2->{filehash}, { targetfile => $file2 });          } -        # otherwise we just use the working copy -        else +        elsif(defined($state->{entries}{$filename}{modified_filename}))          {              $file2 = $state->{entries}{$filename}{modified_filename}; +	    $meta2 = { +                name => $filename, +	        revision => '0', +	        filehash => 'modified' +            };          } - -        # if we have been given -r, and we don't have a $file2 yet, lets -        # get one -        if ( defined ( $revision1 ) and not defined ( $file2 ) ) +        elsif( defined($wrev) && ($wrev!~/^-/) )          { -            ( undef, $file2 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 ); +            if(!defined($revision1))  # no revision and no modifications: +            { +                next; +            }              $meta2 = $updater->getmeta($filename, $wrev); -            transmitfile($meta2->{filehash}, { targetfile => $file2 }); +        } +        if(!defined($file2)) +        { +            if ( !defined($meta2) || +                 $meta2->{filehash} eq "deleted" ) +            { +                if( !exists($state->{opt}{N}) ) +                { +                    if(!defined($revision2)) +                    { +                        print "E File $filename at revision $revision2 doesn't exist\n"; +                    } +                    next; +                } +                elsif( !defined($meta2) ) +                { +	            $meta2 = { +                        name => $filename, +	                revision => '0', +	                filehash => 'deleted' +                    }; +                } +            }          } -        # We need to have retrieved something useful -        next unless ( defined ( $meta1 ) ); - -        # Files to date if the working copy and repo copy have the same -        # revision, and the working copy is unmodified -        if ( not defined ( $meta2 ) and $wrev eq $meta1->{revision} and -             ( ( $state->{entries}{$filename}{unchanged} and -                 ( not defined ( $state->{entries}{$filename}{conflict} ) or -                   $state->{entries}{$filename}{conflict} !~ /^\+=/ ) ) or -               ( defined($state->{entries}{$filename}{modified_hash}) and -                 $state->{entries}{$filename}{modified_hash} eq -                        $meta1->{filehash} ) ) ) +        if( $meta1->{filehash} eq $meta2->{filehash} )          { +            $log->info("unchanged $filename");              next;          } -        # Apparently we only show diffs for locally modified files -        unless ( defined($meta2) or -                 defined ( $state->{entries}{$filename}{modified_filename} ) ) +        # Retrieve revision contents: +        ( undef, $file1 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 ); +        transmitfile($meta1->{filehash}, { targetfile => $file1 }); + +        if(!defined($file2))          { -            next; +            ( undef, $file2 ) = tempfile( DIR => $TEMP_DIR, OPEN => 0 ); +            transmitfile($meta2->{filehash}, { targetfile => $file2 });          } -        print "M Index: $filename\n"; +        # Generate the actual diff: +        print "M Index: $argFilename\n";          print "M =======" . ( "=" x 60 ) . "\n";          print "M RCS file: $state->{CVSROOT}/$state->{module}/$filename,v\n"; -        if ( defined ( $meta1 ) ) +        if ( defined ( $meta1 ) && $meta1->{revision} ne "0" )          {              print "M retrieving revision $meta1->{revision}\n"          } -        if ( defined ( $meta2 ) ) +        if ( defined ( $meta2 ) && $meta2->{revision} ne "0" )          {              print "M retrieving revision $meta2->{revision}\n"          } @@ -1852,33 +2140,73 @@ sub req_diff                  }              }          } -        print "$filename\n"; +        print "$argFilename\n";          $log->info("Diffing $filename -r $meta1->{revision} -r " .                     ( $meta2->{revision} or "workingcopy" )); -        ( $fh, $filediff ) = tempfile ( DIR => $TEMP_DIR ); - -        if ( exists $state->{opt}{u} ) +        # TODO: Use --label instead of -L because -L is no longer +        #  documented and may go away someday.  Not sure if there there are +        #  versions that only support -L, which would make this change risky? +        #  http://osdir.com/ml/bug-gnu-utils-gnu/2010-12/msg00060.html +        #    ("man diff" should actually document the best migration strategy, +        #  [current behavior, future changes, old compatibility issues +        #  or lack thereof, etc], not just stop mentioning the option...) +        # TODO: Real CVS seems to include a date in the label, before +        #  the revision part, without the keyword "revision".  The following +        #  has minimal changes compared to original versions of +        #  git-cvsserver.perl.  (Mostly tab vs space after filename.) + +        my (@diffCmd) = ( 'diff' ); +        if ( exists($state->{opt}{N}) )          { -            system("diff -u -L '$filename revision $meta1->{revision}'" . -                        " -L '$filename " . -                        ( defined($meta2->{revision}) ? -                                "revision $meta2->{revision}" : -                                "working copy" ) . -                        "' $file1 $file2 > $filediff" ); -        } else { -            system("diff $file1 $file2 > $filediff"); +            push @diffCmd,"-N";          } +        if ( exists $state->{opt}{u} ) +        { +            push @diffCmd,("-u","-L"); +            if( $meta1->{filehash} eq "deleted" ) +            { +                push @diffCmd,"/dev/null"; +            } else { +                push @diffCmd,("$argFilename\trevision $meta1->{revision}"); +            } -        while ( <$fh> ) +            if( defined($meta2->{filehash}) ) +            { +                if( $meta2->{filehash} eq "deleted" ) +                { +                    push @diffCmd,("-L","/dev/null"); +                } else { +                    push @diffCmd,("-L", +                                   "$argFilename\trevision $meta2->{revision}"); +                } +            } else { +                push @diffCmd,("-L","$argFilename\tworking copy"); +            } +        } +        push @diffCmd,($file1,$file2); +        if(!open(DIFF,"-|",@diffCmd))          { -            print "M $_"; +            $log->warn("Unable to run diff: $!");          } -        close $fh; +        my($diffLine); +        while(defined($diffLine=<DIFF>)) +        { +            print "M $diffLine"; +            $foundDiff=1; +        } +        close(DIFF);      } -    print "ok\n"; +    if($foundDiff) +    { +        print "error  \n"; +    } +    else +    { +        print "ok\n"; +    }  }  sub req_log @@ -2098,7 +2426,7 @@ sub argsplit          $opt = { A => 0, N => 0, P => 0, R => 0, c => 0, f => 0, l => 0, n => 0, p => 0, s => 0, r => 1, D => 1, d => 1, k => 1, j => 1, } if ( $type eq "co" );          $opt = { v => 0, l => 0, R => 0 } if ( $type eq "status" );          $opt = { A => 0, P => 0, C => 0, d => 0, f => 0, l => 0, R => 0, p => 0, k => 1, r => 1, D => 1, j => 1, I => 1, W => 1 } if ( $type eq "update" ); -        $opt = { l => 0, R => 0, k => 1, D => 1, D => 1, r => 2 } if ( $type eq "diff" ); +        $opt = { l => 0, R => 0, k => 1, D => 1, D => 1, r => 2, N => 0 } if ( $type eq "diff" );          $opt = { c => 0, R => 0, l => 0, f => 0, F => 1, m => 1, r => 1 } if ( $type eq "ci" );          $opt = { k => 1, m => 1 } if ( $type eq "add" );          $opt = { f => 0, l => 0, R => 0 } if ( $type eq "remove" ); @@ -2164,62 +2492,335 @@ sub argsplit      }  } -# This method uses $state->{directory} to populate $state->{args} with a list of filenames -sub argsfromdir +# Used by argsfromdir +sub expandArg  { -    my $updater = shift; +    my ($updater,$outNameMap,$outDirMap,$path,$isDir) = @_; -    $state->{args} = [] if ( scalar(@{$state->{args}}) == 1 and $state->{args}[0] eq "." ); +    my $fullPath = filecleanup($path); -    return if ( scalar ( @{$state->{args}} ) > 1 ); +      # Is it a directory? +    if( defined($state->{dirMap}{$fullPath}) || +        defined($state->{dirMap}{"$fullPath/"}) ) +    { +          # It is a directory in the user's sandbox. +        $isDir=1; -    my @gethead = @{$updater->gethead}; +        if(defined($state->{entries}{$fullPath})) +        { +            $log->fatal("Inconsistent file/dir type"); +            die "Inconsistent file/dir type"; +        } +    } +    elsif(defined($state->{entries}{$fullPath})) +    { +          # It is a file in the user's sandbox. +        $isDir=0; +    } +    my($revDirMap,$otherRevDirMap); +    if(!defined($isDir) || $isDir) +    { +          # Resolve version tree for sticky tag: +          # (for now we only want list of files for the version, not +          # particular versions of those files: assume it is a directory +          # for the moment; ignore Entry's stick tag) + +          # Order of precedence of sticky tags: +          #    -A       [head] +          #    -r /tag/ +          #    [file entry sticky tag, but that is only relevant to files] +          #    [the tag specified in dir req_Sticky] +          #    [the tag specified in a parent dir req_Sticky] +          #    [head] +          # Also, -r may appear twice (for diff). +          # +          # FUTURE: When/if -j (merges) are supported, we also +          #  need to add relevant files from one or two +          #  versions specified with -j. + +        if(exists($state->{opt}{A})) +        { +            $revDirMap=$updater->getRevisionDirMap(); +        } +        elsif( defined($state->{opt}{r}) and +               ref $state->{opt}{r} eq "ARRAY" ) +        { +            $revDirMap=$updater->getRevisionDirMap($state->{opt}{r}[0]); +            $otherRevDirMap=$updater->getRevisionDirMap($state->{opt}{r}[1]); +        } +        elsif(defined($state->{opt}{r})) +        { +            $revDirMap=$updater->getRevisionDirMap($state->{opt}{r}); +        } +        else +        { +            my($sticky)=getDirStickyInfo($fullPath); +            $revDirMap=$updater->getRevisionDirMap($sticky->{tag}); +        } -    # push added files -    foreach my $file (keys %{$state->{entries}}) { -	if ( exists $state->{entries}{$file}{revision} && -		$state->{entries}{$file}{revision} eq '0' ) -	{ -	    push @gethead, { name => $file, filehash => 'added' }; -	} +          # Is it a directory? +        if( defined($revDirMap->{$fullPath}) || +            defined($otherRevDirMap->{$fullPath}) ) +        { +            $isDir=1; +        }      } -    if ( scalar(@{$state->{args}}) == 1 ) +      # What to do with it? +    if(!$isDir) +    { +        $outNameMap->{$fullPath}=1; +    } +    else      { -        my $arg = $state->{args}[0]; -        $arg .= $state->{prependdir} if ( defined ( $state->{prependdir} ) ); +        $outDirMap->{$fullPath}=1; -        $log->info("Only one arg specified, checking for directory expansion on '$arg'"); +        if(defined($revDirMap->{$fullPath})) +        { +            addDirMapFiles($updater,$outNameMap,$outDirMap, +                           $revDirMap->{$fullPath}); +        } +        if( defined($otherRevDirMap) && +            defined($otherRevDirMap->{$fullPath}) ) +        { +            addDirMapFiles($updater,$outNameMap,$outDirMap, +                           $otherRevDirMap->{$fullPath}); +        } +    } +} + +# Used by argsfromdir +# Add entries from dirMap to outNameMap.  Also recurse into entries +# that are subdirectories. +sub addDirMapFiles +{ +    my($updater,$outNameMap,$outDirMap,$dirMap)=@_; -        foreach my $file ( @gethead ) +    my($fullName); +    foreach $fullName (keys(%$dirMap)) +    { +        my $cleanName=$fullName; +        if(defined($state->{prependdir}))          { -            next if ( $file->{filehash} eq "deleted" and not defined ( $state->{entries}{$file->{name}} ) ); -            next unless ( $file->{name} =~ /^$arg\// or $file->{name} eq $arg  ); -            push @{$state->{args}}, $file->{name}; +            if(!($cleanName=~s/^\Q$state->{prependdir}\E//)) +            { +                $log->fatal("internal error stripping prependdir"); +                die "internal error stripping prependdir"; +            }          } -        shift @{$state->{args}} if ( scalar(@{$state->{args}}) > 1 ); -    } else { -        $log->info("Only one arg specified, populating file list automatically"); +        if($dirMap->{$fullName} eq "F") +        { +            $outNameMap->{$cleanName}=1; +        } +        elsif($dirMap->{$fullName} eq "D") +        { +            if(!$state->{opt}{l}) +            { +                expandArg($updater,$outNameMap,$outDirMap,$cleanName,1); +            } +        } +        else +        { +            $log->fatal("internal error in addDirMapFiles"); +            die "internal error in addDirMapFiles"; +        } +    } +} + +# This method replaces $state->{args} with a directory-expanded +# list of all relevant filenames (recursively unless -d), based +# on $state->{entries}, and the "current" list of files in +# each directory.  "Current" files as determined by +# either the requested (-r/-A) or "req_Sticky" version of +# that directory. +#    Both the input args and the new output args are relative +# to the cvs-client's CWD, although some of the internal +# computations are relative to the top of the project. +sub argsfromdir +{ +    my $updater = shift; + +    # Notes about requirements for specific callers: +    #   update # "standard" case (entries; a single -r/-A/default; -l) +    #          # Special case: -d for create missing directories. +    #   diff # 0 or 1 -r's: "standard" case. +    #        # 2 -r's: We could ignore entries (just use the two -r's), +    #        # but it doesn't really matter. +    #   annotate # "standard" case +    #   log # Punting: log -r has a more complex non-"standard" +    #       # meaning, and we don't currently try to support log'ing +    #       # branches at all (need a lot of work to +    #       # support CVS-consistent branch relative version +    #       # numbering). +#HERE: But we still want to expand directories.  Maybe we should +#  essentially force "-A". +    #   status # "standard", except that -r/-A/default are not possible. +    #          # Mostly only used to expand entries only) +    # +    # Don't use argsfromdir at all: +    #   add # Explicit arguments required.  Directory args imply add +    #       # the directory itself, not the files in it. +    #   co  # Obtain list directly. +    #   remove # HERE: TEST: MAYBE client does the recursion for us, +    #          # since it only makes sense to remove stuff already in +    #          # the sandobx? +    #   ci # HERE: Similar to remove... +    #      # Don't try to implement the confusing/weird +    #      # ci -r bug er.."feature". + +    if(scalar(@{$state->{args}})==0) +    { +        $state->{args} = [ "." ]; +    } +    my %allArgs; +    my %allDirs; +    for my $file (@{$state->{args}}) +    { +        expandArg($updater,\%allArgs,\%allDirs,$file); +    } + +    # Include any entries from sandbox.  Generally client won't +    # send entries that shouldn't be used. +    foreach my $file (keys %{$state->{entries}}) +    { +        $allArgs{remove_prependdir($file)} = 1; +    } -        $state->{args} = []; +    $state->{dirArgs} = \%allDirs; +    $state->{args} = [ +        sort { +                # Sort priority: by directory depth, then actual file name: +            my @piecesA=split('/',$a); +            my @piecesB=split('/',$b); -        foreach my $file ( @gethead ) +            my $count=scalar(@piecesA); +            my $tmp=scalar(@piecesB); +            return $count<=>$tmp if($count!=$tmp); + +            for($tmp=0;$tmp<$count;$tmp++) +            { +                if($piecesA[$tmp] ne $piecesB[$tmp]) +                { +                    return $piecesA[$tmp] cmp $piecesB[$tmp] +                } +            } +            return 0; +        } keys(%allArgs) ]; +} + +## look up directory sticky tag, of either fullPath or a parent: +sub getDirStickyInfo +{ +    my($fullPath)=@_; + +    $fullPath=~s%/+$%%; +    while($fullPath ne "" && !defined($state->{dirMap}{"$fullPath/"})) +    { +        $fullPath=~s%/?[^/]*$%%; +    } + +    if( !defined($state->{dirMap}{"$fullPath/"}) && +        ( $fullPath eq "" || +          $fullPath eq "." ) ) +    { +        return $state->{dirMap}{""}{stickyInfo}; +    } +    else +    { +        return $state->{dirMap}{"$fullPath/"}{stickyInfo}; +    } +} + +# Resolve precedence of various ways of specifying which version of +# a file you want.  Returns undef (for default head), or a ref to a hash +# that contains "tag" and/or "date" keys. +sub resolveStickyInfo +{ +    my($filename,$stickyTag,$stickyDate,$reset) = @_; + +    # Order of precedence of sticky tags: +    #    -A       [head] +    #    -r /tag/ +    #    [file entry sticky tag] +    #    [the tag specified in dir req_Sticky] +    #    [the tag specified in a parent dir req_Sticky] +    #    [head] + +    my $result; +    if($reset) +    { +        # $result=undef; +    } +    elsif( defined($stickyTag) && $stickyTag ne "" ) +           # || ( defined($stickyDate) && $stickyDate ne "" )   # TODO +    { +        $result={ 'tag' => (defined($stickyTag)?$stickyTag:undef) }; + +        # TODO: Convert -D value into the form 2011.04.10.04.46.57, +        #   similar to an entry line's sticky date, without the D prefix. +        #   It sometimes (always?) arrives as something more like +        #   '10 Apr 2011 04:46:57 -0000'... +        # $result={ 'date' => (defined($stickyDate)?$stickyDate:undef) }; +    } +    elsif( defined($state->{entries}{$filename}) && +           defined($state->{entries}{$filename}{tag_or_date}) && +           $state->{entries}{$filename}{tag_or_date} ne "" ) +    { +        my($tagOrDate)=$state->{entries}{$filename}{tag_or_date}; +        if($tagOrDate=~/^T([^ ]+)\s*$/)          { -            next if ( $file->{filehash} eq "deleted" and not defined ( $state->{entries}{$file->{name}} ) ); -            next unless ( $file->{name} =~ s/^$state->{prependdir}// ); -            push @{$state->{args}}, $file->{name}; +            $result = { 'tag' => $1 }; +        } +        elsif($tagOrDate=~/^D([0-9.]+)\s*$/) +        { +            $result= { 'date' => $1 }; +        } +        else +        { +            die "Unknown tag_or_date format\n";          }      } +    else +    { +        $result=getDirStickyInfo($filename); +    } + +    return $result; +} + +# Convert a stickyInfo (ref to a hash) as returned by resolveStickyInfo into +# a form appropriate for the sticky tag field of an Entries +# line (field index 5, 0-based). +sub getStickyTagOrDate +{ +    my($stickyInfo)=@_; + +    my $result; +    if(defined($stickyInfo) && defined($stickyInfo->{tag})) +    { +        $result="T$stickyInfo->{tag}"; +    } +    # TODO: When/if we actually pick versions by {date} properly, +    #   also handle it here: +    #   "D$stickyInfo->{date}" (example: "D2011.04.13.20.37.07"). +    else +    { +        $result=""; +    } + +    return $result;  }  # This method cleans up the $state variable after a command that uses arguments has run  sub statecleanup  {      $state->{files} = []; +    $state->{dirArgs} = {};      $state->{args} = [];      $state->{arguments} = [];      $state->{entries} = {}; +    $state->{dirMap} = {};  }  # Return working directory CVS revision "1.X" out @@ -2309,6 +2910,9 @@ sub filenamesplit      return ( $filepart, $dirpart );  } +# Cleanup various junk in filename (try to canonicalize it), and +# add prependdir to accomodate running CVS client from a +# subdirectory (so the output is relative to top directory of the project).  sub filecleanup  {      my $filename = shift; @@ -2320,11 +2924,36 @@ sub filecleanup          return undef;      } +    if($filename eq ".") +    { +        $filename=""; +    }      $filename =~ s/^\.\///g; +    $filename =~ s%/+%/%g;      $filename = $state->{prependdir} . $filename; +    $filename =~ s%/$%%;      return $filename;  } +# Remove prependdir from the path, so that is is relative to the directory +# the CVS client was started from, rather than the top of the project. +# Essentially the inverse of filecleanup(). +sub remove_prependdir +{ +    my($path) = @_; +    if(defined($state->{prependdir}) && $state->{prependdir} ne "") +    { +        my($pre)=$state->{prependdir}; +        $pre=~s%/$%%; +        if(!($path=~s%^\Q$pre\E/?%%)) +        { +            $log->fatal("internal error missing prependdir"); +            die("internal error missing prependdir"); +        } +    } +    return $path; +} +  sub validateGitDir  {      if( !defined($state->{CVSROOT}) ) @@ -2738,6 +3367,45 @@ sub descramble      return $ret;  } +# Test if the (deep) values of two references to a hash are the same. +sub refHashEqual +{ +    my($v1,$v2) = @_; + +    my $out; +    if(!defined($v1)) +    { +        if(!defined($v2)) +        { +            $out=1; +        } +    } +    elsif( !defined($v2) || +           scalar(keys(%{$v1})) != scalar(keys(%{$v2})) ) +    { +        # $out=undef; +    } +    else +    { +        $out=1; + +        my $key; +        foreach $key (keys(%{$v1})) +        { +            if( !exists($v2->{$key}) || +                defined($v1->{$key}) ne defined($v2->{$key}) || +                ( defined($v1->{$key}) && +                  $v1->{$key} ne $v2->{$key} ) ) +            { +               $out=undef; +               last; +            } +        } +    } + +    return $out; +} +  package GITCVS::log; @@ -2958,6 +3626,9 @@ sub new      die "Git repo '$self->{git_path}' doesn't exist" unless ( -d $self->{git_path} ); +    # Stores full sha1's for various branch/tag names, abbreviations, etc: +    $self->{commitRefCache} = {}; +      $self->{dbdriver} = $cfg->{gitcvs}{$state->{method}}{dbdriver} ||          $cfg->{gitcvs}{dbdriver} || "SQLite";      $self->{dbname} = $cfg->{gitcvs}{$state->{method}}{dbname} || @@ -3140,6 +3811,8 @@ sub update      my $lastcommit = $self->_get_prop("last_commit");      if (defined $lastcommit && $lastcommit eq $commitsha1) { # up-to-date +         # invalidate the gethead cache +         $self->clearCommitRefCaches();           return 1;      } @@ -3157,45 +3830,10 @@ sub update          push @git_log_params, $self->{module};      }      # git-rev-list is the backend / plumbing version of git-log -    open(GITLOG, '-|', 'git', 'rev-list', @git_log_params) or die "Cannot call git-rev-list: $!"; - -    my @commits; - -    my %commit = (); - -    while ( <GITLOG> ) -    { -        chomp; -        if (m/^commit\s+(.*)$/) { -            # on ^commit lines put the just seen commit in the stack -            # and prime things for the next one -            if (keys %commit) { -                my %copy = %commit; -                unshift @commits, \%copy; -                %commit = (); -            } -            my @parents = split(m/\s+/, $1); -            $commit{hash} = shift @parents; -            $commit{parents} = \@parents; -        } elsif (m/^(\w+?):\s+(.*)$/ && !exists($commit{message})) { -            # on rfc822-like lines seen before we see any message, -            # lowercase the entry and put it in the hash as key-value -            $commit{lc($1)} = $2; -        } else { -            # message lines - skip initial empty line -            # and trim whitespace -            if (!exists($commit{message}) && m/^\s*$/) { -                # define it to mark the end of headers -                $commit{message} = ''; -                next; -            } -            s/^\s+//; s/\s+$//; # trim ws -            $commit{message} .= $_ . "\n"; -        } -    } -    close GITLOG; - -    unshift @commits, \%commit if ( keys %commit ); +    open(my $gitLogPipe, '-|', 'git', 'rev-list', @git_log_params) +                or die "Cannot call git-rev-list: $!"; +    my @commits=readCommits($gitLogPipe); +    close $gitLogPipe;      # Now all the commits are in the @commits bucket      # ordered by time DESC. for each commit that needs processing, @@ -3294,7 +3932,7 @@ sub update          }          # convert the date to CVS-happy format -        $commit->{date} = "$2 $1 $4 $3 $5" if ( $commit->{date} =~ /^\w+\s+(\w+)\s+(\d+)\s+(\d+:\d+:\d+)\s+(\d+)\s+([+-]\d+)$/ ); +        my $cvsDate = convertToCvsDate($commit->{date});          if ( defined ( $lastpicked ) )          { @@ -3303,7 +3941,7 @@ sub update              while ( <FILELIST> )              {  		chomp; -                unless ( /^:\d{6}\s+\d{3}(\d)\d{2}\s+[a-zA-Z0-9]{40}\s+([a-zA-Z0-9]{40})\s+(\w)$/o ) +                unless ( /^:\d{6}\s+([0-7]{6})\s+[a-f0-9]{40}\s+([a-f0-9]{40})\s+(\w)$/o )                  {                      die("Couldn't process git-diff-tree line : $_");                  } @@ -3313,11 +3951,7 @@ sub update                  # $log->debug("File mode=$mode, hash=$hash, change=$change, name=$name"); -                my $git_perms = ""; -                $git_perms .= "r" if ( $mode & 4 ); -                $git_perms .= "w" if ( $mode & 2 ); -                $git_perms .= "x" if ( $mode & 1 ); -                $git_perms = "rw" if ( $git_perms eq "" ); +                my $dbMode = convertToDbMode($mode);                  if ( $change eq "D" )                  { @@ -3327,11 +3961,11 @@ sub update                          revision => $head->{$name}{revision} + 1,                          filehash => "deleted",                          commithash => $commit->{hash}, -                        modified => $commit->{date}, +                        modified => $cvsDate,                          author => $commit->{author}, -                        mode => $git_perms, +                        mode => $dbMode,                      }; -                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $commit->{date}, $commit->{author}, $git_perms); +                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $cvsDate, $commit->{author}, $dbMode);                  }                  elsif ( $change eq "M" || $change eq "T" )                  { @@ -3341,11 +3975,11 @@ sub update                          revision => $head->{$name}{revision} + 1,                          filehash => $hash,                          commithash => $commit->{hash}, -                        modified => $commit->{date}, +                        modified => $cvsDate,                          author => $commit->{author}, -                        mode => $git_perms, +                        mode => $dbMode,                      }; -                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $commit->{date}, $commit->{author}, $git_perms); +                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $cvsDate, $commit->{author}, $dbMode);                  }                  elsif ( $change eq "A" )                  { @@ -3355,11 +3989,11 @@ sub update                          revision => $head->{$name}{revision} ? $head->{$name}{revision}+1 : 1,                          filehash => $hash,                          commithash => $commit->{hash}, -                        modified => $commit->{date}, +                        modified => $cvsDate,                          author => $commit->{author}, -                        mode => $git_perms, +                        mode => $dbMode,                      }; -                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $commit->{date}, $commit->{author}, $git_perms); +                    $self->insert_rev($name, $head->{$name}{revision}, $hash, $commit->{hash}, $cvsDate, $commit->{author}, $dbMode);                  }                  else                  { @@ -3382,7 +4016,7 @@ sub update                      die("Couldn't process git-ls-tree line : $_");                  } -                my ( $git_perms, $git_type, $git_hash, $git_filename ) = ( $1, $2, $3, $4 ); +                my ( $mode, $git_type, $git_hash, $git_filename ) = ( $1, $2, $3, $4 );                  $seen_files->{$git_filename} = 1; @@ -3392,18 +4026,10 @@ sub update                      $head->{$git_filename}{mode}                  ); -                if ( $git_perms =~ /^\d\d\d(\d)\d\d/o ) -                { -                    $git_perms = ""; -                    $git_perms .= "r" if ( $1 & 4 ); -                    $git_perms .= "w" if ( $1 & 2 ); -                    $git_perms .= "x" if ( $1 & 1 ); -                } else { -                    $git_perms = "rw"; -                } +                my $dbMode = convertToDbMode($mode);                  # unless the file exists with the same hash, we need to update it ... -                unless ( defined($oldhash) and $oldhash eq $git_hash and defined($oldmode) and $oldmode eq $git_perms ) +                unless ( defined($oldhash) and $oldhash eq $git_hash and defined($oldmode) and $oldmode eq $dbMode )                  {                      my $newrevision = ( $oldrevision or 0 ) + 1; @@ -3412,13 +4038,13 @@ sub update                          revision => $newrevision,                          filehash => $git_hash,                          commithash => $commit->{hash}, -                        modified => $commit->{date}, +                        modified => $cvsDate,                          author => $commit->{author}, -                        mode => $git_perms, +                        mode => $dbMode,                      }; -                    $self->insert_rev($git_filename, $newrevision, $git_hash, $commit->{hash}, $commit->{date}, $commit->{author}, $git_perms); +                    $self->insert_rev($git_filename, $newrevision, $git_hash, $commit->{hash}, $cvsDate, $commit->{author}, $dbMode);                  }              }              close FILELIST; @@ -3431,10 +4057,10 @@ sub update                      $head->{$file}{revision}++;                      $head->{$file}{filehash} = "deleted";                      $head->{$file}{commithash} = $commit->{hash}; -                    $head->{$file}{modified} = $commit->{date}; +                    $head->{$file}{modified} = $cvsDate;                      $head->{$file}{author} = $commit->{author}; -                    $self->insert_rev($file, $head->{$file}{revision}, $head->{$file}{filehash}, $commit->{hash}, $commit->{date}, $commit->{author}, $head->{$file}{mode}); +                    $self->insert_rev($file, $head->{$file}{revision}, $head->{$file}{filehash}, $commit->{hash}, $cvsDate, $commit->{author}, $head->{$file}{mode});                  }              }              # END : "Detect deleted files" @@ -3465,13 +4091,94 @@ sub update          );      }      # invalidate the gethead cache -    $self->{gethead_cache} = undef; +    $self->clearCommitRefCaches();      # Ending exclusive lock here      $self->{dbh}->commit() or die "Failed to commit changes to SQLite";  } +sub readCommits +{ +    my $pipeHandle = shift; +    my @commits; + +    my %commit = (); + +    while ( <$pipeHandle> ) +    { +        chomp; +        if (m/^commit\s+(.*)$/) { +            # on ^commit lines put the just seen commit in the stack +            # and prime things for the next one +            if (keys %commit) { +                my %copy = %commit; +                unshift @commits, \%copy; +                %commit = (); +            } +            my @parents = split(m/\s+/, $1); +            $commit{hash} = shift @parents; +            $commit{parents} = \@parents; +        } elsif (m/^(\w+?):\s+(.*)$/ && !exists($commit{message})) { +            # on rfc822-like lines seen before we see any message, +            # lowercase the entry and put it in the hash as key-value +            $commit{lc($1)} = $2; +        } else { +            # message lines - skip initial empty line +            # and trim whitespace +            if (!exists($commit{message}) && m/^\s*$/) { +                # define it to mark the end of headers +                $commit{message} = ''; +                next; +            } +            s/^\s+//; s/\s+$//; # trim ws +            $commit{message} .= $_ . "\n"; +        } +    } + +    unshift @commits, \%commit if ( keys %commit ); + +    return @commits; +} + +sub convertToCvsDate +{ +    my $date = shift; +    # Convert from: "git rev-list --pretty" formatted date +    # Convert to: "the format specified by RFC822 as modified by RFC1123." +    # Example: 26 May 1997 13:01:40 -0400 +    if( $date =~ /^\w+\s+(\w+)\s+(\d+)\s+(\d+:\d+:\d+)\s+(\d+)\s+([+-]\d+)$/ ) +    { +        $date = "$2 $1 $4 $3 $5"; +    } + +    return $date; +} + +sub convertToDbMode +{ +    my $mode = shift; + +    # NOTE: The CVS protocol uses a string similar "u=rw,g=rw,o=rw", +    #  but the database "mode" column historically (and currently) +    #  only stores the "rw" (for user) part of the string. +    #    FUTURE: It might make more sense to persist the raw +    #  octal mode (or perhaps the final full CVS form) instead of +    #  this half-converted form, but it isn't currently worth the +    #  backwards compatibility headaches. + +    $mode=~/^\d\d(\d)\d{3}$/; +    my $userBits=$1; + +    my $dbMode = ""; +    $dbMode .= "r" if ( $userBits & 4 ); +    $dbMode .= "w" if ( $userBits & 2 ); +    $dbMode .= "x" if ( $userBits & 1 ); +    $dbMode = "rw" if ( $dbMode eq "" ); + +    return $dbMode; +} +  sub insert_rev  {      my $self = shift; @@ -3586,6 +4293,169 @@ sub gethead      return $tree;  } +=head2 getAnyHead + +Returns a reference to an array of getmeta structures, one +per file in the specified tree hash. + +=cut + +sub getAnyHead +{ +    my ($self,$hash) = @_; + +    if(!defined($hash)) +    { +        return $self->gethead(); +    } + +    my @files; +    { +        open(my $filePipe, '-|', 'git', 'ls-tree', '-z', '-r', $hash) +                or die("Cannot call git-ls-tree : $!"); +        local $/ = "\0"; +        @files=<$filePipe>; +        close $filePipe; +    } + +    my $tree=[]; +    my($line); +    foreach $line (@files) +    { +        $line=~s/\0$//; +        unless ( $line=~/^(\d+)\s+(\w+)\s+([a-zA-Z0-9]+)\t(.*)$/o ) +        { +            die("Couldn't process git-ls-tree line : $_"); +        } + +        my($mode, $git_type, $git_hash, $git_filename) = ($1, $2, $3, $4); +        push @$tree, $self->getMetaFromCommithash($git_filename,$hash); +    } + +    return $tree; +} + +=head2 getRevisionDirMap + +A "revision dir map" contains all the plain-file filenames associated +with a particular revision (treeish), organized by directory: + +  $type = $out->{$dir}{$fullName} + +The type of each is "F" (for ordinary file) or "D" (for directory, +for which the map $out->{$fullName} will also exist). + +=cut + +sub getRevisionDirMap +{ +    my ($self,$ver)=@_; + +    if(!defined($self->{revisionDirMapCache})) +    { +        $self->{revisionDirMapCache}={}; +    } + +        # Get file list (previously cached results are dependent on HEAD, +        # but are early in each case): +    my $cacheKey; +    my (@fileList); +    if( !defined($ver) || $ver eq "" ) +    { +        $cacheKey=""; +        if( defined($self->{revisionDirMapCache}{$cacheKey}) ) +        { +            return $self->{revisionDirMapCache}{$cacheKey}; +        } + +        my @head = @{$self->gethead()}; +        foreach my $file ( @head ) +        { +            next if ( $file->{filehash} eq "deleted" ); + +            push @fileList,$file->{name}; +        } +    } +    else +    { +        my ($hash)=$self->lookupCommitRef($ver); +        if( !defined($hash) ) +        { +            return undef; +        } + +        $cacheKey=$hash; +        if( defined($self->{revisionDirMapCache}{$cacheKey}) ) +        { +            return $self->{revisionDirMapCache}{$cacheKey}; +        } + +        open(my $filePipe, '-|', 'git', 'ls-tree', '-z', '-r', $hash) +                or die("Cannot call git-ls-tree : $!"); +        local $/ = "\0"; +        while ( <$filePipe> ) +        { +            chomp; +            unless ( /^(\d+)\s+(\w+)\s+([a-zA-Z0-9]+)\t(.*)$/o ) +            { +                die("Couldn't process git-ls-tree line : $_"); +            } + +            my($mode, $git_type, $git_hash, $git_filename) = ($1, $2, $3, $4); + +            push @fileList, $git_filename; +        } +        close $filePipe; +    } + +        # Convert to normalized form: +    my %revMap; +    my $file; +    foreach $file (@fileList) +    { +        my($dir) = ($file=~m%^(?:(.*)/)?([^/]*)$%); +        $dir='' if(!defined($dir)); + +            # parent directories: +            # ... create empty dir maps for parent dirs: +        my($td)=$dir; +        while(!defined($revMap{$td})) +        { +            $revMap{$td}={}; + +            my($tp)=($td=~m%^(?:(.*)/)?([^/]*)$%); +            $tp='' if(!defined($tp)); +            $td=$tp; +        } +            # ... add children to parent maps (now that they exist): +        $td=$dir; +        while($td ne "") +        { +            my($tp)=($td=~m%^(?:(.*)/)?([^/]*)$%); +            $tp='' if(!defined($tp)); + +            if(defined($revMap{$tp}{$td})) +            { +                if($revMap{$tp}{$td} ne 'D') +                { +                    die "Weird file/directory inconsistency in $cacheKey"; +                } +                last;   # loop exit +            } +            $revMap{$tp}{$td}='D'; + +            $td=$tp; +        } + +            # file +        $revMap{$dir}{$file}='F'; +    } + +        # Save in cache: +    $self->{revisionDirMapCache}{$cacheKey}=\%revMap; +    return $self->{revisionDirMapCache}{$cacheKey}; +} +  =head2 getlog  See also gethistorydense(). @@ -3646,6 +4516,19 @@ sub getlog  This function takes a filename (with path) argument and returns a hashref of  metadata for that file. +There are several ways $revision can be specified: + +   - A reference to hash that contains a "tag" that is the +     actual revision (one of the below).  TODO: Also allow it to +     specify a "date" in the hash. +   - undef, to refer to the latest version on the main branch. +   - Full CVS client revision number (mapped to integer in DB, without the +     "1." prefix), +   - Complex CVS-compatible "special" revision number for +     non-linear history (see comment below) +   - git commit sha1 hash +   - branch or tag name +  =cut  sub getmeta @@ -3656,23 +4539,144 @@ sub getmeta      my $tablename_rev = $self->tablename("revision");      my $tablename_head = $self->tablename("head"); -    my $db_query; -    if ( defined($revision) and $revision =~ /^1\.(\d+)$/ ) +    if ( ref($revision) eq "HASH" )      { -        my ($intRev) = $1; -        $db_query = $self->{dbh}->prepare_cached("SELECT * FROM $tablename_rev WHERE name=? AND revision=?",{},1); -        $db_query->execute($filename, $intRev); +        $revision = $revision->{tag};      } -    elsif ( defined($revision) and $revision =~ /^[a-zA-Z0-9]{40}$/ ) + +    # Overview of CVS revision numbers: +    # +    # General CVS numbering scheme: +    #   - Basic mainline branch numbers: "1.1", "1.2", "1.3", etc. +    #   - Result of "cvs checkin -r" (possible, but not really +    #     recommended): "2.1", "2.2", etc +    #   - Branch tag: "1.2.0.n", where "1.2" is revision it was branched +    #     from, "0" is a magic placeholder that identifies it as a +    #     branch tag instead of a version tag, and n is 2 times the +    #     branch number off of "1.2", starting with "2". +    #   - Version on a branch: "1.2.n.x", where "1.2" is branch-from, "n" +    #     is branch number off of "1.2" (like n above), and "x" is +    #     the version number on the branch. +    #   - Branches can branch off of branches: "1.3.2.7.4.1" (even number +    #     of components). +    #   - Odd "n"s are used by "vendor branches" that result +    #     from "cvs import".  Vendor branches have additional +    #     strangeness in the sense that the main rcs "head" of the main +    #     branch will (temporarily until first normal commit) point +    #     to the version on the vendor branch, rather than the actual +    #     main branch.  (FUTURE: This may provide an opportunity +    #     to use "strange" revision numbers for fast-forward-merged +    #     branch tip when CVS client is asking for the main branch.) +    # +    # git-cvsserver CVS-compatible special numbering schemes: +    #   - Currently git-cvsserver only tries to be identical to CVS for +    #     simple "1.x" numbers on the "main" branch (as identified +    #     by the module name that was originally cvs checkout'ed). +    #   - The database only stores the "x" part, for historical reasons. +    #     But most of the rest of the cvsserver preserves +    #     and thinks using the full revision number. +    #   - To handle non-linear history, it uses a version of the form +    #     "2.1.1.2000.b.b.b."..., where the 2.1.1.2000 is to help uniquely +    #     identify this as a special revision number, and there are +    #     20 b's that together encode the sha1 git commit from which +    #     this version of this file originated.  Each b is +    #     the numerical value of the corresponding byte plus +    #     100. +    #      - "plus 100" avoids "0"s, and also reduces the +    #        likelyhood of a collision in the case that someone someday +    #        writes an import tool that tries to preserve original +    #        CVS revision numbers, and the original CVS data had done +    #        lots of branches off of branches and other strangeness to +    #        end up with a real version number that just happens to look +    #        like this special revision number form.  Also, if needed +    #        there are several ways to extend/identify alternative encodings +    #        within the "2.1.1.2000" part if necessary. +    #      - Unlike real CVS revisions, you can't really reconstruct what +    #        relation a revision of this form has to other revisions. +    #   - FUTURE: TODO: Rework database somehow to make up and remember +    #     fully-CVS-compatible branches and branch version numbers. + +    my $meta; +    if ( defined($revision) )      { -        $db_query = $self->{dbh}->prepare_cached("SELECT * FROM $tablename_rev WHERE name=? AND commithash=?",{},1); -        $db_query->execute($filename, $revision); -    } else { -        $db_query = $self->{dbh}->prepare_cached("SELECT * FROM $tablename_head WHERE name=?",{},1); +        if ( $revision =~ /^1\.(\d+)$/ ) +        { +            my ($intRev) = $1; +            my $db_query; +            $db_query = $self->{dbh}->prepare_cached( +                "SELECT * FROM $tablename_rev WHERE name=? AND revision=?", +                {},1); +            $db_query->execute($filename, $intRev); +            $meta = $db_query->fetchrow_hashref; +        } +        elsif ( $revision =~ /^2\.1\.1\.2000(\.[1-3][0-9][0-9]){20}$/ ) +        { +            my ($commitHash)=($revision=~/^2\.1\.1\.2000(.*)$/); +            $commitHash=~s/\.([0-9]+)/sprintf("%02x",$1-100)/eg; +            if($commitHash=~/^[0-9a-f]{40}$/) +            { +                return $self->getMetaFromCommithash($filename,$commitHash); +            } + +            # error recovery: fall back on head version below +            print "E Failed to find $filename version=$revision or commit=$commitHash\n"; +            $log->warning("failed get $revision with commithash=$commitHash"); +            undef $revision; +        } +        elsif ( $revision =~ /^[0-9a-f]{40}$/ ) +        { +            # Try DB first.  This is mostly only useful for req_annotate(), +            # which only calls this for stuff that should already be in +            # the DB.  It is fairly likely to be a waste of time +            # in most other cases [unless the file happened to be +            # modified in $revision specifically], but +            # it is probably in the noise compared to how long +            # getMetaFromCommithash() will take. +            my $db_query; +            $db_query = $self->{dbh}->prepare_cached( +                "SELECT * FROM $tablename_rev WHERE name=? AND commithash=?", +                {},1); +            $db_query->execute($filename, $revision); +            $meta = $db_query->fetchrow_hashref; + +            if(! $meta) +            { +                my($revCommit)=$self->lookupCommitRef($revision); +                if($revCommit=~/^[0-9a-f]{40}$/) +                { +                    return $self->getMetaFromCommithash($filename,$revCommit); +                } + +                # error recovery: nothing found: +                print "E Failed to find $filename version=$revision\n"; +                $log->warning("failed get $revision"); +                return $meta; +            } +        } +        else +        { +            my($revCommit)=$self->lookupCommitRef($revision); +            if($revCommit=~/^[0-9a-f]{40}$/) +            { +                return $self->getMetaFromCommithash($filename,$revCommit); +            } + +            # error recovery: fall back on head version below +            print "E Failed to find $filename version=$revision\n"; +            $log->warning("failed get $revision"); +            undef $revision;  # Allow fallback +        } +    } + +    if(!defined($revision)) +    { +        my $db_query; +        $db_query = $self->{dbh}->prepare_cached( +                "SELECT * FROM $tablename_head WHERE name=?",{},1);          $db_query->execute($filename); +        $meta = $db_query->fetchrow_hashref;      } -    my $meta = $db_query->fetchrow_hashref;      if($meta)      {          $meta->{revision} = "1.$meta->{revision}"; @@ -3680,6 +4684,204 @@ sub getmeta      return $meta;  } +sub getMetaFromCommithash +{ +    my $self = shift; +    my $filename = shift; +    my $revCommit = shift; + +    # NOTE: This function doesn't scale well (lots of forks), especially +    #   if you have many files that have not been modified for many commits +    #   (each git-rev-parse redoes a lot of work for each file +    #   that theoretically could be done in parallel by smarter +    #   graph traversal). +    # +    # TODO: Possible optimization strategies: +    #   - Solve the issue of assigning and remembering "real" CVS +    #     revision numbers for branches, and ensure the +    #     data structure can do this efficiently.  Perhaps something +    #     similar to "git notes", and carefully structured to take +    #     advantage same-sha1-is-same-contents, to roll the same +    #     unmodified subdirectory data onto multiple commits? +    #   - Write and use a C tool that is like git-blame, but +    #     operates on multiple files with file granularity, instead +    #     of one file with line granularity.  Cache +    #     most-recently-modified in $self->{commitRefCache}{$revCommit}. +    #     Try to be intelligent about how many files we do with +    #     one fork (perhaps one directory at a time, without recursion, +    #     and/or include directory as one line item, recurse from here +    #     instead of in C tool?). +    #   - Perhaps we could ask the DB for (filename,fileHash), +    #     and just guess that it is correct (that the file hadn't +    #     changed between $revCommit and the found commit, then +    #     changed back, confusing anything trying to interpret +    #     history).  Probably need to add another index to revisions +    #     DB table for this. +    #   - NOTE: Trying to store all (commit,file) keys in DB [to +    #     find "lastModfiedCommit] (instead of +    #     just files that changed in each commit as we do now) is +    #     probably not practical from a disk space perspective. + +        # Does the file exist in $revCommit? +    # TODO: Include file hash in dirmap cache. +    my($dirMap)=$self->getRevisionDirMap($revCommit); +    my($dir,$file)=($filename=~m%^(?:(.*)/)?([^/]*$)%); +    if(!defined($dir)) +    { +        $dir=""; +    } +    if( !defined($dirMap->{$dir}) || +        !defined($dirMap->{$dir}{$filename}) ) +    { +        my($fileHash)="deleted"; + +        my($retVal)={}; +        $retVal->{name}=$filename; +        $retVal->{filehash}=$fileHash; + +            # not needed and difficult to compute: +        $retVal->{revision}="0";  # $revision; +        $retVal->{commithash}=$revCommit; +        #$retVal->{author}=$commit->{author}; +        #$retVal->{modified}=convertToCvsDate($commit->{date}); +        #$retVal->{mode}=convertToDbMode($mode); + +        return $retVal; +    } + +    my($fileHash)=safe_pipe_capture("git","rev-parse","$revCommit:$filename"); +    chomp $fileHash; +    if(!($fileHash=~/^[0-9a-f]{40}$/)) +    { +        die "Invalid fileHash '$fileHash' looking up" +                    ." '$revCommit:$filename'\n"; +    } + +    # information about most recent commit to modify $filename: +    open(my $gitLogPipe, '-|', 'git', 'rev-list', +         '--max-count=1', '--pretty', '--parents', +         $revCommit, '--', $filename) +                or die "Cannot call git-rev-list: $!"; +    my @commits=readCommits($gitLogPipe); +    close $gitLogPipe; +    if(scalar(@commits)!=1) +    { +        die "Can't find most recent commit changing $filename\n"; +    } +    my($commit)=$commits[0]; +    if( !defined($commit) || !defined($commit->{hash}) ) +    { +        return undef; +    } + +    # does this (commit,file) have a real assigned CVS revision number? +    my $tablename_rev = $self->tablename("revision"); +    my $db_query; +    $db_query = $self->{dbh}->prepare_cached( +        "SELECT * FROM $tablename_rev WHERE name=? AND commithash=?", +        {},1); +    $db_query->execute($filename, $commit->{hash}); +    my($meta)=$db_query->fetchrow_hashref; +    if($meta) +    { +        $meta->{revision} = "1.$meta->{revision}"; +        return $meta; +    } + +    # fall back on special revision number +    my($revision)=$commit->{hash}; +    $revision=~s/(..)/'.' . (hex($1)+100)/eg; +    $revision="2.1.1.2000$revision"; + +    # meta data about $filename: +    open(my $filePipe, '-|', 'git', 'ls-tree', '-z', +                $commit->{hash}, '--', $filename) +            or die("Cannot call git-ls-tree : $!"); +    local $/ = "\0"; +    my $line; +    $line=<$filePipe>; +    if(defined(<$filePipe>)) +    { +        die "Expected only a single file for git-ls-tree $filename\n"; +    } +    close $filePipe; + +    chomp $line; +    unless ( $line=~m/^(\d+)\s+(\w+)\s+([a-zA-Z0-9]+)\t(.*)$/o ) +    { +        die("Couldn't process git-ls-tree line : $line\n"); +    } +    my ( $mode, $git_type, $git_hash, $git_filename ) = ( $1, $2, $3, $4 ); + +    # save result: +    my($retVal)={}; +    $retVal->{name}=$filename; +    $retVal->{revision}=$revision; +    $retVal->{filehash}=$fileHash; +    $retVal->{commithash}=$revCommit; +    $retVal->{author}=$commit->{author}; +    $retVal->{modified}=convertToCvsDate($commit->{date}); +    $retVal->{mode}=convertToDbMode($mode); + +    return $retVal; +} + +=head2 lookupCommitRef + +Convert tag/branch/abbreviation/etc into a commit sha1 hash.  Caches +the result so looking it up again is fast. + +=cut + +sub lookupCommitRef +{ +    my $self = shift; +    my $ref = shift; + +    my $commitHash = $self->{commitRefCache}{$ref}; +    if(defined($commitHash)) +    { +        return $commitHash; +    } + +    $commitHash=safe_pipe_capture("git","rev-parse","--verify","--quiet", +                                  $self->unescapeRefName($ref)); +    $commitHash=~s/\s*$//; +    if(!($commitHash=~/^[0-9a-f]{40}$/)) +    { +        $commitHash=undef; +    } + +    if( defined($commitHash) ) +    { +        my $type=safe_pipe_capture("git","cat-file","-t",$commitHash); +        if( ! ($type=~/^commit\s*$/ ) ) +        { +            $commitHash=undef; +        } +    } +    if(defined($commitHash)) +    { +        $self->{commitRefCache}{$ref}=$commitHash; +    } +    return $commitHash; +} + +=head2 clearCommitRefCaches + +Clears cached commit cache (sha1's for various tags/abbeviations/etc), +and related caches. + +=cut + +sub clearCommitRefCaches +{ +    my $self = shift; +    $self->{commitRefCache} = {}; +    $self->{revisionDirMapCache} = undef; +    $self->{gethead_cache} = undef; +} +  =head2 commitmessage  this function takes a commithash and returns the commit message for that commit @@ -3745,6 +4947,97 @@ sub gethistorydense      return $result;  } +=head2 escapeRefName + +Apply an escape mechanism to compensate for characters that +git ref names can have that CVS tags can not. + +=cut +sub escapeRefName +{ +    my($self,$refName)=@_; + +    # CVS officially only allows [-_A-Za-z0-9] in tag names (or in +    # many contexts it can also be a CVS revision number). +    # +    # Git tags commonly use '/' and '.' as well, but also handle +    # anything else just in case: +    # +    #   = "_-s-"  For '/'. +    #   = "_-p-"  For '.'. +    #   = "_-u-"  For underscore, in case someone wants a literal "_-" in +    #     a tag name. +    #   = "_-xx-" Where "xx" is the hexadecimal representation of the +    #     desired ASCII character byte. (for anything else) + +    if(! $refName=~/^[1-9][0-9]*(\.[1-9][0-9]*)*$/) +    { +        $refName=~s/_-/_-u--/g; +        $refName=~s/\./_-p-/g; +        $refName=~s%/%_-s-%g; +        $refName=~s/[^-_a-zA-Z0-9]/sprintf("_-%02x-",$1)/eg; +    } +} + +=head2 unescapeRefName + +Undo an escape mechanism to compensate for characters that +git ref names can have that CVS tags can not. + +=cut +sub unescapeRefName +{ +    my($self,$refName)=@_; + +    # see escapeRefName() for description of escape mechanism. + +    $refName=~s/_-([spu]|[0-9a-f][0-9a-f])-/unescapeRefNameChar($1)/eg; + +    # allowed tag names +    # TODO: Perhaps use git check-ref-format, with an in-process cache of +    #  validated names? +    if( !( $refName=~m%^[^-][-a-zA-Z0-9_/.]*$% ) || +        ( $refName=~m%[/.]$% ) || +        ( $refName=~/\.lock$/ ) || +        ( $refName=~m%\.\.|/\.|[[\\:?*~]|\@\{% ) )  # matching } +    { +        # Error: +        $log->warn("illegal refName: $refName"); +        $refName=undef; +    } +    return $refName; +} + +sub unescapeRefNameChar +{ +    my($char)=@_; + +    if($char eq "s") +    { +        $char="/"; +    } +    elsif($char eq "p") +    { +        $char="."; +    } +    elsif($char eq "u") +    { +        $char="_"; +    } +    elsif($char=~/^[0-9a-f][0-9a-f]$/) +    { +        $char=chr(hex($char)); +    } +    else +    { +        # Error case: Maybe it has come straight from user, and +        # wasn't supposed to be escaped?  Restore it the way we got it: +        $char="_-$char-"; +    } + +    return $char; +} +  =head2 in_array()  from Array::PAT - mimics the in_array() function diff --git a/t/t9402-git-cvsserver-refs.sh b/t/t9402-git-cvsserver-refs.sh new file mode 100755 index 0000000000..735a018ecc --- /dev/null +++ b/t/t9402-git-cvsserver-refs.sh @@ -0,0 +1,551 @@ +#!/bin/sh + +test_description='git-cvsserver and git refspecs + +tests ability for git-cvsserver to switch between and compare +tags, branches and other git refspecs' + +. ./test-lib.sh + +######### + +check_start_tree() { +	rm -f "$WORKDIR/list.expected" +	echo "start $1" >>"${WORKDIR}/check.log" +} + +check_file() { +	sandbox="$1" +	file="$2" +	ver="$3" +	GIT_DIR=$SERVERDIR git show "${ver}:${file}" \ +		>"$WORKDIR/check.got" 2>"$WORKDIR/check.stderr" +	test_cmp "$WORKDIR/check.got" "$sandbox/$file" +	stat=$? +	echo "check_file $sandbox $file $ver : $stat" >>"$WORKDIR/check.log" +	echo "$file" >>"$WORKDIR/list.expected" +	return $stat +} + +check_end_tree() { +	sandbox="$1" && +	find "$sandbox" -name CVS -prune -o -type f -print >"$WORKDIR/list.actual" && +	sort <"$WORKDIR/list.expected" >expected && +	sort <"$WORKDIR/list.actual" | sed -e "s%cvswork/%%" >actual && +	test_cmp expected actual && +	rm expected actual +} + +check_end_full_tree() { +	sandbox="$1" && +	sort <"$WORKDIR/list.expected" >expected && +	find "$sandbox" -name CVS -prune -o -type f -print | +	sed -e "s%$sandbox/%%" | sort >act1 && +	test_cmp expected act1 && +	git ls-tree --name-only -r "$2" | sort >act2 && +	test_cmp expected act2 && +	rm expected act1 act2 +} + +######### + +check_diff() { +	diffFile="$1" +	vOld="$2" +	vNew="$3" +	rm -rf diffSandbox +	git clone -q -n . diffSandbox && +	( +		cd diffSandbox && +		git checkout "$vOld" && +		git apply -p0 --index <"../$diffFile" && +		git diff --exit-code "$vNew" +	) >check_diff_apply.out 2>&1 +} + +######### + +cvs >/dev/null 2>&1 +if test $? -ne 1 +then +	skip_all='skipping git-cvsserver tests, cvs not found' +	test_done +fi +if ! test_have_prereq PERL +then +	skip_all='skipping git-cvsserver tests, perl not available' +	test_done +fi +"$PERL_PATH" -e 'use DBI; use DBD::SQLite' >/dev/null 2>&1 || { +	skip_all='skipping git-cvsserver tests, Perl SQLite interface unavailable' +	test_done +} + +unset GIT_DIR GIT_CONFIG +WORKDIR=$(pwd) +SERVERDIR=$(pwd)/gitcvs.git +git_config="$SERVERDIR/config" +CVSROOT=":fork:$SERVERDIR" +CVSWORK="$(pwd)/cvswork" +CVS_SERVER=git-cvsserver +export CVSROOT CVS_SERVER + +rm -rf "$CVSWORK" "$SERVERDIR" +test_expect_success 'setup v1, b1' ' +	echo "Simple text file" >textfile.c && +	echo "t2" >t2 && +	mkdir adir && +	echo "adir/afile line1" >adir/afile && +	echo "adir/afile line2" >>adir/afile && +	echo "adir/afile line3" >>adir/afile && +	echo "adir/afile line4" >>adir/afile && +	echo "adir/a2file" >>adir/a2file && +	mkdir adir/bdir && +	echo "adir/bdir/bfile line 1" >adir/bdir/bfile && +	echo "adir/bdir/bfile line 2" >>adir/bdir/bfile && +	echo "adir/bdir/b2file" >adir/bdir/b2file && +	git add textfile.c t2 adir && +	git commit -q -m "First Commit (v1)" && +	git tag v1 && +	git branch b1 && +	git clone -q --bare "$WORKDIR/.git" "$SERVERDIR" >/dev/null 2>&1 && +	GIT_DIR="$SERVERDIR" git config --bool gitcvs.enabled true && +	GIT_DIR="$SERVERDIR" git config gitcvs.logfile "$SERVERDIR/gitcvs.log" +' + +rm -rf cvswork +test_expect_success 'cvs co v1' ' +	cvs -f -Q co -r v1 -d cvswork master >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +rm -rf cvswork +test_expect_success 'cvs co b1' ' +	cvs -f co -r b1 -d cvswork master >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +test_expect_success 'cvs co b1 [cvswork3]' ' +	cvs -f co -r b1 -d cvswork3 master >cvs.log 2>&1 && +	check_start_tree cvswork3 && +	check_file cvswork3 textfile.c v1 && +	check_file cvswork3 t2 v1 && +	check_file cvswork3 adir/afile v1 && +	check_file cvswork3 adir/a2file v1 && +	check_file cvswork3 adir/bdir/bfile v1 && +	check_file cvswork3 adir/bdir/b2file v1 && +	check_end_full_tree cvswork3 v1 +' + +test_expect_success 'edit cvswork3 and save diff' ' +	( +		cd cvswork3 && +		sed -e "s/line1/line1 - data/" adir/afile >adir/afileNEW && +		mv -f adir/afileNEW adir/afile && +		echo "afile5" >adir/afile5 && +		rm t2 && +		cvs -f add adir/afile5 && +		cvs -f rm t2 && +		! cvs -f diff -N -u >"$WORKDIR/cvswork3edit.diff" +	) +' + +test_expect_success 'setup v1.2 on b1' ' +	git checkout b1 && +	echo "new v1.2" >t3 && +	rm t2 && +	sed -e "s/line3/line3 - more data/" adir/afile >adir/afileNEW && +	mv -f adir/afileNEW adir/afile && +	rm adir/a2file && +	echo "a3file" >>adir/a3file && +	echo "bfile line 3" >>adir/bdir/bfile && +	rm adir/bdir/b2file && +	echo "b3file" >adir/bdir/b3file && +	mkdir cdir && +	echo "cdir/cfile" >cdir/cfile && +	git add -A cdir adir t3 t2 && +	git commit -q -m 'v1.2' && +	git tag v1.2 && +	git push --tags gitcvs.git b1:b1 +' + +test_expect_success 'cvs -f up (on b1 adir)' ' +	( cd cvswork/adir && cvs -f up -d ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1.2 && +	check_file cvswork adir/a3file v1.2 && +	check_file cvswork adir/bdir/bfile v1.2 && +	check_file cvswork adir/bdir/b3file v1.2 && +	check_end_tree cvswork +' + +test_expect_success 'cvs up (on b1 /)' ' +	( cd cvswork && cvs -f up -d ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1.2 && +	check_file cvswork t3 v1.2 && +	check_file cvswork adir/afile v1.2 && +	check_file cvswork adir/a3file v1.2 && +	check_file cvswork adir/bdir/bfile v1.2 && +	check_file cvswork adir/bdir/b3file v1.2 && +	check_file cvswork cdir/cfile v1.2 && +	check_end_tree cvswork +' + +# Make sure "CVS/Tag" files didn't get messed up: +test_expect_success 'cvs up (on b1 /) (again; check CVS/Tag files)' ' +	( cd cvswork && cvs -f up -d ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1.2 && +	check_file cvswork t3 v1.2 && +	check_file cvswork adir/afile v1.2 && +	check_file cvswork adir/a3file v1.2 && +	check_file cvswork adir/bdir/bfile v1.2 && +	check_file cvswork adir/bdir/b3file v1.2 && +	check_file cvswork cdir/cfile v1.2 && +	check_end_tree cvswork +' + +# update to another version: +test_expect_success 'cvs up -r v1' ' +	( cd cvswork && cvs -f up -r v1 ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +test_expect_success 'cvs up' ' +	( cd cvswork && cvs -f up ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +test_expect_success 'cvs up (again; check CVS/Tag files)' ' +	( cd cvswork && cvs -f up -d ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +test_expect_success 'setup simple b2' ' +	git branch b2 v1 && +	git push --tags gitcvs.git b2:b2 +' + +test_expect_success 'cvs co b2 [into cvswork2]' ' +	cvs -f co -r b2 -d cvswork2 master >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_tree cvswork +' + +test_expect_success 'root dir edit [cvswork2]' ' +	( +		cd cvswork2 && echo "Line 2" >>textfile.c && +		! cvs -f diff -u >"$WORKDIR/cvsEdit1.diff" && +		cvs -f commit -m "edit textfile.c" textfile.c +	) >cvsEdit1.log 2>&1 +' + +test_expect_success 'root dir rm file [cvswork2]' ' +	( +		cd cvswork2 && +		cvs -f rm -f t2 && +		cvs -f diff -u >../cvsEdit2-empty.diff && +		! cvs -f diff -N -u >"$WORKDIR/cvsEdit2-N.diff" && +		cvs -f commit -m "rm t2" +	) >cvsEdit2.log 2>&1 +' + +test_expect_success 'subdir edit/add/rm files [cvswork2]' ' +	( +		cd cvswork2 && +		sed -e "s/line 1/line 1 (v2)/" adir/bdir/bfile >adir/bdir/bfileNEW && +		mv -f adir/bdir/bfileNEW adir/bdir/bfile && +		rm adir/bdir/b2file && +		cd adir && +		cvs -f rm bdir/b2file && +		echo "4th file" >bdir/b4file && +		cvs -f add bdir/b4file && +		! cvs -f diff -N -u >"$WORKDIR/cvsEdit3.diff" && +		git fetch gitcvs.git b2:b2 && +		( +		  cd .. && +		  ! cvs -f diff -u -N -r v1.2 >"$WORKDIR/cvsEdit3-v1.2.diff" && +		  ! cvs -f diff -u -N -r v1.2 -r v1 >"$WORKDIR/cvsEdit3-v1.2-v1.diff" +		) && +		cvs -f commit -m "various add/rm/edit" +	) >cvs.log 2>&1 +' + +test_expect_success 'validate result of edits [cvswork2]' ' +	git fetch gitcvs.git b2:b2 && +	git tag v2 b2 && +	git push --tags gitcvs.git b2:b2 && +	check_start_tree cvswork2 && +	check_file cvswork2 textfile.c v2 && +	check_file cvswork2 adir/afile v2 && +	check_file cvswork2 adir/a2file v2 && +	check_file cvswork2 adir/bdir/bfile v2 && +	check_file cvswork2 adir/bdir/b4file v2 && +	check_end_full_tree cvswork2 v2 +' + +test_expect_success 'validate basic diffs saved during above cvswork2 edits' ' +	test $(grep Index: cvsEdit1.diff | wc -l) = 1 && +	test ! -s cvsEdit2-empty.diff && +	test $(grep Index: cvsEdit2-N.diff | wc -l) = 1 && +	test $(grep Index: cvsEdit3.diff | wc -l) = 3 && +	rm -rf diffSandbox && +	git clone -q -n . diffSandbox && +	( +		cd diffSandbox && +		git checkout v1 && +		git apply -p0 --index <"$WORKDIR/cvsEdit1.diff" && +		git apply -p0 --index <"$WORKDIR/cvsEdit2-N.diff" && +		git apply -p0 --directory=adir --index <"$WORKDIR/cvsEdit3.diff" && +		git diff --exit-code v2 +	) >"check_diff_apply.out" 2>&1 +' + +test_expect_success 'validate v1.2 diff saved during last cvswork2 edit' ' +	test $(grep Index: cvsEdit3-v1.2.diff | wc -l) = 9 && +	check_diff cvsEdit3-v1.2.diff v1.2 v2 +' + +test_expect_success 'validate v1.2 v1 diff saved during last cvswork2 edit' ' +	test $(grep Index: cvsEdit3-v1.2-v1.diff | wc -l) = 9 && +	check_diff cvsEdit3-v1.2-v1.diff v1.2 v1 +' + +test_expect_success 'cvs up [cvswork2]' ' +	( cd cvswork2 && cvs -f up ) >cvs.log 2>&1 && +	check_start_tree cvswork2 && +	check_file cvswork2 textfile.c v2 && +	check_file cvswork2 adir/afile v2 && +	check_file cvswork2 adir/a2file v2 && +	check_file cvswork2 adir/bdir/bfile v2 && +	check_file cvswork2 adir/bdir/b4file v2 && +	check_end_full_tree cvswork2 v2 +' + +test_expect_success 'cvs up -r b2 [back to cvswork]' ' +	( cd cvswork && cvs -f up -r b2 ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v2 && +	check_file cvswork adir/afile v2 && +	check_file cvswork adir/a2file v2 && +	check_file cvswork adir/bdir/bfile v2 && +	check_file cvswork adir/bdir/b4file v2 && +	check_end_full_tree cvswork v2 +' + +test_expect_success 'cvs up -r b1' ' +	( cd cvswork && cvs -f up -r b1 ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1.2 && +	check_file cvswork t3 v1.2 && +	check_file cvswork adir/afile v1.2 && +	check_file cvswork adir/a3file v1.2 && +	check_file cvswork adir/bdir/bfile v1.2 && +	check_file cvswork adir/bdir/b3file v1.2 && +	check_file cvswork cdir/cfile v1.2 && +	check_end_full_tree cvswork v1.2 +' + +test_expect_success 'cvs up -A' ' +	( cd cvswork && cvs -f up -A ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_full_tree cvswork v1 +' + +test_expect_success 'cvs up (check CVS/Tag files)' ' +	( cd cvswork && cvs -f up ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_full_tree cvswork v1 +' + +# This is not really legal CVS, but it seems to work anyway: +test_expect_success 'cvs up -r heads/b1' ' +	( cd cvswork && cvs -f up -r heads/b1 ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1.2 && +	check_file cvswork t3 v1.2 && +	check_file cvswork adir/afile v1.2 && +	check_file cvswork adir/a3file v1.2 && +	check_file cvswork adir/bdir/bfile v1.2 && +	check_file cvswork adir/bdir/b3file v1.2 && +	check_file cvswork cdir/cfile v1.2 && +	check_end_full_tree cvswork v1.2 +' + +# But this should work even if CVS client checks -r more carefully: +test_expect_success 'cvs up -r heads_-s-b2 (cvsserver escape mechanism)' ' +	( cd cvswork && cvs -f up -r heads_-s-b2 ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v2 && +	check_file cvswork adir/afile v2 && +	check_file cvswork adir/a2file v2 && +	check_file cvswork adir/bdir/bfile v2 && +	check_file cvswork adir/bdir/b4file v2 && +	check_end_full_tree cvswork v2 +' + +v1hash=$(git rev-parse v1) +test_expect_success 'cvs up -r $(git rev-parse v1)' ' +	test -n "$v1hash" && +	( cd cvswork && cvs -f up -r "$v1hash" ) >cvs.log 2>&1 && +	check_start_tree cvswork && +	check_file cvswork textfile.c v1 && +	check_file cvswork t2 v1 && +	check_file cvswork adir/afile v1 && +	check_file cvswork adir/a2file v1 && +	check_file cvswork adir/bdir/bfile v1 && +	check_file cvswork adir/bdir/b2file v1 && +	check_end_full_tree cvswork v1 +' + +test_expect_success 'cvs diff -r v1 -u' ' +	( cd cvswork && cvs -f diff -r v1 -u ) >cvsDiff.out 2>cvs.log && +	test ! -s cvsDiff.out && +	test ! -s cvs.log +' + +test_expect_success 'cvs diff -N -r v2 -u' ' +	( cd cvswork && ! cvs -f diff -N -r v2 -u ) >cvsDiff.out 2>cvs.log && +	test ! -s cvs.log && +	test -s cvsDiff.out && +	check_diff cvsDiff.out v2 v1 >check_diff.out 2>&1 +' + +test_expect_success 'cvs diff -N -r v2 -r v1.2' ' +	( cd cvswork && ! cvs -f diff -N -r v2 -r v1.2 -u ) >cvsDiff.out 2>cvs.log && +	test ! -s cvs.log && +	test -s cvsDiff.out && +	check_diff cvsDiff.out v2 v1.2 >check_diff.out 2>&1 +' + +test_expect_success 'apply early [cvswork3] diff to b3' ' +	git clone -q . gitwork3 && +	( +		cd gitwork3 && +		git checkout -b b3 v1 && +		git apply -p0 --index <"$WORKDIR/cvswork3edit.diff" && +		git commit -m "cvswork3 edits applied" +	) && +	git fetch gitwork3 b3:b3 && +	git tag v3 b3 +' + +test_expect_success 'check [cvswork3] diff' ' +	( cd cvswork3 && ! cvs -f diff -N -u ) >"$WORKDIR/cvsDiff.out" 2>cvs.log && +	test ! -s cvs.log && +	test -s cvsDiff.out && +	test $(grep Index: cvsDiff.out | wc -l) = 3 && +	test_cmp cvsDiff.out cvswork3edit.diff && +	check_diff cvsDiff.out v1 v3 >check_diff.out 2>&1 +' + +test_expect_success 'merge early [cvswork3] b3 with b1' ' +	( cd gitwork3 && git merge "message" HEAD b1 ) && +	git fetch gitwork3 b3:b3 && +	git tag v3merged b3 && +	git push --tags gitcvs.git b3:b3 +' + +# This test would fail if cvsserver properly created a ".#afile"* file +# for the merge. +# TODO: Validate that the .# file was saved properly, and then +#   delete/ignore it when checking the tree. +test_expect_success 'cvs up dirty [cvswork3]' ' +	( +		cd cvswork3 && +		cvs -f up && +		! cvs -f diff -N -u >"$WORKDIR/cvsDiff.out" +	) >cvs.log 2>&1 && +	test -s cvsDiff.out && +	test $(grep Index: cvsDiff.out | wc -l) = 2 && +	check_start_tree cvswork3 && +	check_file cvswork3 textfile.c v3merged && +	check_file cvswork3 t3 v3merged && +	check_file cvswork3 adir/afile v3merged && +	check_file cvswork3 adir/a3file v3merged && +	check_file cvswork3 adir/afile5 v3merged && +	check_file cvswork3 adir/bdir/bfile v3merged && +	check_file cvswork3 adir/bdir/b3file v3merged && +	check_file cvswork3 cdir/cfile v3merged && +	check_end_full_tree cvswork3 v3merged +' + +# TODO: test cvs status + +test_expect_success 'cvs commit [cvswork3]' ' +	( +		cd cvswork3 && +		cvs -f commit -m "dirty sandbox after auto-merge" +	) >cvs.log 2>&1 && +	check_start_tree cvswork3 && +	check_file cvswork3 textfile.c v3merged && +	check_file cvswork3 t3 v3merged && +	check_file cvswork3 adir/afile v3merged && +	check_file cvswork3 adir/a3file v3merged && +	check_file cvswork3 adir/afile5 v3merged && +	check_file cvswork3 adir/bdir/bfile v3merged && +	check_file cvswork3 adir/bdir/b3file v3merged && +	check_file cvswork3 cdir/cfile v3merged && +	check_end_full_tree cvswork3 v3merged && +	git fetch gitcvs.git b3:b4 && +	git tag v4.1 b4 && +	git diff --exit-code v4.1 v3merged >check_diff_apply.out 2>&1 +' + +test_done  | 
