#!/usr/bin/env perl
#
#  svnci.pl  -- Utility script that removes files that are deleted from 
#               the log message from the commit.
#
#  Copyright 2006 Troy Curtis Jr. <troycurtisjr@gmail.com>
#
#  This program is free software; you can redistribute  it and/or modify it
#  under  the terms of  the GNU General  Public License as published by the
#  Free Software Foundation;  either version 2 of the  License, or (at your
#  option) any later version.
#
#  THIS  SOFTWARE  IS PROVIDED   ``AS  IS'' AND   ANY  EXPRESS  OR  IMPLIED
#  WARRANTIES,   INCLUDING, BUT NOT  LIMITED  TO, THE IMPLIED WARRANTIES OF
#  MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN
#  NO  EVENT  SHALL   THE AUTHOR  BE    LIABLE FOR ANY   DIRECT,  INDIRECT,
#  INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
#  NOT LIMITED   TO, PROCUREMENT OF  SUBSTITUTE GOODS  OR SERVICES; LOSS OF
#  USE, DATA,  OR PROFITS; OR  BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
#  ANY THEORY OF LIABILITY, WHETHER IN  CONTRACT, STRICT LIABILITY, OR TORT
#  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
#  THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
#
#  You should have received a copy of the  GNU General Public License along
#  with this program; if not, write  to the Free Software Foundation, Inc.,
#  59 Temple Place - Suite 330, Boston MA 02111-1307 USA.
#
#------------------------------------------------------------------------------

# $Id: svnci.pl 24 2006-09-20 04:33:48Z troycurtisjr $
# $HeadURL: https://troyjr.hopto.org/projects/svn/svnci/tags/0.1/svnci.pl $

# You can get the latest file anonymously by using http:// in place of https://

#------------------------------------------------------------------------------
# This script is designed to function as a drop-in replacement for using
# 'svn commit'.  It parses all of the command-line arguments that the 1.4 
# version of svn takes.  
# 
# Ultimately I strived to make svnci.pl behave the same way 'svn commit' would
# with the notable exception that you can delete files from the log message
# to remove them from the commit.  Additionally, if you have a blank log
# message this script will only ask you to abort or try again.  Implementing a
# blank commit option seemed like it was going to be more trouble than it was
# worth.  Besides, you shouldn't use blank log messages!  Please let me know of 
# any deviations from normal 'svn commit' behavior that bug you.   
#
# If you supply a message, log message filename, or targets file on the command 
# line, this script will simply pass the arguments  directly to 'svn commit'. 
# This is done because being able to delete files # from your commit does not 
# make sense in these cases. 
#
# Feel free to send me patches or suggestions.
#------------------------------------------------------------------------------
use strict;

#------------------------------------------------------------------------------
# User Configuration Section
#------------------------------------------------------------------------------
# The name of the svn executable you want to use with this script
my $svn_exec        = 'svn';

# The editor to use if checks to SVN_EDITOR, VISUAL, and EDITOR fail. 
my $default_editor  = 'vim'; 

# The base and extension of the temporary file to save the log message to.  It
# defaults to the svn-commit[#].tmp filename that the svn client uses.
my $tmpfile_base    = 'svn-commit';
my $tmpfile_ext     = '.tmp';

# The name of the temporary targets file to use when providing 'svn commit'
# with a list of files to commit.  I did this so that I would not have to fight
# the shell with special characters.
my $targets_name    = 'svnci-targets.tmp';

# The *single* line that you want to seperate your log message from your
# filelist.  I have included a commented out version of the 'svn' seperator for
# convience (syntax files, etc).  Of course, the real seperator isn't exactly
# true anymore if you use this script. :-)
my $log_seperator   = "--Type your log message above this line.  You may delete files from the commit below.--";  
#my $log_seperator   = "--This line, and those below, will be ignored--";

#------------------------------------------------------------------------------
# Packages 
#------------------------------------------------------------------------------
use Getopt::Long; 


#------------------------------------------------------------------------------
#  Globals
#------------------------------------------------------------------------------
my @old_commit_logs = ();
my %args = ('verbose' => 0); # Initialize verbose to zero
my %commit_candidates = ();

# Option strings to pass to the Getopts::Long module.
# See the format_option subroutine for why we need to define these two arrays.

# Boolean options.  Either true or false.  
my @bool_options = ( 'quiet|q',
                     'no-unlock',
                     'no-auth-cache',
                     'non-interactive',
                     'non-recursive|N');

# Options that may have arguments associated with them.
my @arg_options   = ( 'message|m=s',
                      'file|F=s',
                      'force-log',
                      'editor-cmd=s',
                      'encoding=s',     
                      'username=s',     
                      'password=s',     
                      'targets=s',  
                      'config-dir=s');

# Options that should be passed through to the status command
my @status_valid_options = ( 'quiet|q',
                             'non-recursive|N',
                             'username=s',     
                             'password=s',     
                             'no-auth-cache',
                             'non-interactive',
                             'config-dir=s');  

my $status_options = ''; # Command line options to use when calling the 'svn status' command
my $commit_options = ''; # Command line options to use when calling the 'svn commit' command
my $file_list      = ''; # All the other args on the command line
my $log_file       = ''; 


#------------------------------------------------------------------------------
# Function Definitions 
#------------------------------------------------------------------------------

###############################################################################
# trace(level,message)
#   
#   - level:   The verbosity level of the supplied message
#   - message: A string containing the message to (maybe) display to the user
#
#   Returns: Nothing
#
# Used anytime we want to say something to the user, allows for varying levels
# of verbosity.  Mostly useful for debugging/development, but there is no
# reason to take it out.
###############################################################################
sub trace 
{
    die "trace only accepts two options!\n" unless (scalar @_ == 2) ;

    (my $level, my $message) = @_;

    print $message unless $level > $args{'verbose'};
}

###############################################################################
# getTmpFile()
#
#   Returns: Filename of the temporary log message file
#
# Finds the first available filename using the $tmpfile_base[#].$tmpfile_ext
# format (just like subversion).
###############################################################################
sub getTmpFile 
{
    my $num = "";
    my $tmp_filename = '';

    # Loop through possible names of the tmp file until a free one is found
    while ( -f ($tmp_filename = $tmpfile_base . $num . $tmpfile_ext) ) 
    {
        push @old_commit_logs, $tmp_filename;

        # If the $num variable is null, then we need to start the count, otherwise just increment by one
        if ( $num == '' ) { $num = 1 }
        else              { $num++ } 
    }

    return $tmp_filename;
}

###############################################################################
# generateLogTemplate(log_filename,commitlist)
#
#   - log_filename: The filename to store the log message in 
#   - commitlist:   A string containing the list of files available for commit
# 
#   Returns: Nothing 
#
# Generates the log message template that is given to the user's editor.  This
# consists of inserting the $log_seperator followed by the commitlist. 
###############################################################################
sub generateLogTemplate 
{
    (my $log_filename, my $commitlist) = @_;

    #print "log_filename: $log_filename\ncommitlist: $commitlist\n";

    if ( $commitlist eq "" ) 
    {
        die "You must have at least one file or directory in the commitlist\n";
    }

    open LOG_FILE,">$log_filename" or die "Cannot open $log_filename for writing: $!\n";

    print LOG_FILE "\n";   # Print a new line to make it easier to start a log message
    print LOG_FILE $log_seperator . "\n\n";
    print LOG_FILE $commitlist . "\n";

    close LOG_FILE or die "Error closing $log_filename: $!\n";
}

###############################################################################
# format_option(option,value)
#   
#   - option: Name of the option to format
#   - value:  Value of the option to format
#
#   Returns: The formatted option string
#
# Generates a formatted option string that can be passed to the 'svn' CLI
# client.  This is needed because the Getopt::Long module gives a 0/1 value for 
# boolean options. 
###############################################################################
sub format_option
{
    die "format_option must have 2 arguments!\n" unless scalar @_ == 2;

    (my $option, my $value) = @_;

    # Check to see if this is expected to be a bool option
    foreach (@bool_options)
    {
        if (/($option)/)
        {
            trace 5, "$option was found in the bool option array\n";
            return " --$option ";
        }
    }

    if ( $value eq '' )
    {
        trace 5, "$option has a NULL value\n";
        return " --$option ";
    }

    trace 5, "$option has a value \"$value\"\n";
    return " --$option=\"$value\" ";

}

###############################################################################
# callExternalEditor(log_filename)
# 
#   - log_filename: The filename of the log message to give to the editor.
#
#   Returns: Exist status of the system call that executed the editor command.
#
# Opens the given log file with an editor.  The CLI specified 'editor-cmd'
# takes priority, followed by SVN_EDITOR, VISUAL, EDITOR, and finally
# $default_editor (just like svn...mostly). 
###############################################################################
sub callExternalEditor
{
    if ( exists ($args{'editor-cmd'}))
    {
        trace 3, "Using the command line supplied editor: $args{'editor-cmd'}\n";
        return(system($args{'editor-cmd'},@_));
    } elsif ( exists ($ENV{'SVN_EDITOR'}) && ($ENV{'SVN_EDITOR'} != ''))
    {
        trace 3, "Using the editor in SVN_EDITOR: $ENV{'SVN_EDITOR'}\n";
        return(system($ENV{'SVN_EDITOR'},@_));
    } elsif ( exists ($ENV{'VISUAL'}) && ($ENV{'VISUAL'} != ''))
    {
        trace 3, "Using the editor in VISUAL: $ENV{'VISUAL'}\n";
        return(system($ENV{'VISUAL'},@_));
    } elsif ( exists ($ENV{'EDITOR'}) && ($ENV{'EDITOR'} != ''))
    {
        trace 3, "Using the editor in EDITOR: $ENV{'EDITOR'}\n";
        return(system($ENV{'EDITOR'},@_));
    } else
    {
        trace 3, "Using the default editor $default_editor\n"; 
        return(system($default_editor,@_));
    }
}

###############################################################################
# parseCmdLine()
#
#   Returns: Nothing
# 
# Parse all of the command line parameters.  We strip out the added 'verbose'
# option, as that is only used by svnci.pl.  We also build the option strings to
# pass to both the 'svn status' (to get the commit candidates) and 'svn commit'
# commands. 
###############################################################################
sub parseCmdLine 
{
   Getopt::Long::Configure("bundling");
   GetOptions( \%args, 
                   'verbose|v+',
                   @bool_options,
                   @arg_options
                   );  

   while( (my $option, my $value) = each %args )
   {
       next if $option =~ /verbose/; # We are using 'verbose' for this script so just skip it

       # Loop through all the valid status options, if we find a match
       # with the current option, then add it to the status option string
       foreach (@status_valid_options)
       {
           if ( /($option)/ )
           {
               trace 4, "Adding $option/$value to status_options\n";
               $status_options .= format_option($option,$value);
           }
       }

       $commit_options .= format_option($option,$value);

       trace 3,"$option specified with $value\n";
   }

   # Now stick the rest of the args into the $file_list var
   $file_list = "@ARGV";

   trace 2, "status_options: $status_options\n";
   trace 2, "commit_options: $commit_options\n";
   trace 2, "file_list     : $file_list\n";

}

###############################################################################
# parseLogFile(filename)
#
#   - filename: Filename of the log file to parse
#
#   Returns: Length of log message (i.e. 0 means an empty log message). 
#
# This is the core function of this script!  This is the function responsible
# for parsing the user supplied log file and determining which file to commit
# and with what log message.
#
# Logic:
#   * Test for an empty log message
#   * Leave the log message in the file 
#   * Get the list of files that the user left (and thus wants to commit)
#   * Truncate the file at the $log_seperator line
#   * Delete any file paths from the global $commit_candidates hash if the user
#     deleted them from the log message. 
#   * Return the length of the log message
###############################################################################
sub parseLogFile
{
    (my $filename) = @_;
    my $log_message = '';
    my $log_filelist = '';
    my $end_of_msg_addr;

    # Open the log file for reading
    open LOG_FILE,"+< $filename"
        or die "Unable to open $filename for update: $!\n"; 

    # Save off the log message, which is all the text up to the $log_seperator
    while(<LOG_FILE>)
    {
        last if /^($log_seperator)$/;

        # Save the address of the last line of the log message
        $end_of_msg_addr = tell(LOG_FILE); 
        $log_message .= $_;
    }


    trace 4, "The log message was:\n $log_message \n";

    # We can continue processing the log message as long as there is at least 
    # one non-whitespace character
    unless ( $log_message =~ /[^ \t\n]/s ) 
    {
        close LOG_FILE;
        return 0;
    }

    # Grab all of the file paths that the user left
    while(<LOG_FILE>)
    {
        # Skip blank lines
        next if (/^[ \t]*$/);

        $log_filelist .= $_;   
    }

    trace 4, "The user left this filelist:\n $log_filelist \n";

    # Loop through all the commit_candidates that we created from the status message 
    # and delete any from the hash that do not exist in the log_filelist
    foreach my $commit_file (keys %commit_candidates)
    {
        delete($commit_candidates{"$commit_file"}) 
            unless ($log_filelist =~ /($commit_candidates{"$commit_file"})/);
    }

    trace 4, "We are left with these commit candidates:\n" . 
              (keys %commit_candidates) . "\n";

    # Truncate the file at the end of the log message
    truncate(LOG_FILE, $end_of_msg_addr);
    close LOG_FILE;
    return length($log_message);
}

###############################################################################
# cleanupFiles()
# 
#  Returns: Nothing
#
# Clean up any temporary files created by svnci.pl.
###############################################################################
sub cleanupFiles
{
      system('rm','-f',$log_file,$targets_name);
}

#------------------------------------------------------------------------------
# MAIN -- The entry point for this script, don't ask me why this is implemented
#         as a function which is simply called directly after its definition, I 
#         do not know :-). 
#------------------------------------------------------------------------------
sub Main 
{
    my $status_output = '';
    my $commit_list = '';

    # Parse the command line arguments.  Basically we will just 
    # pass all of these on to 'svn commit' with the exception of 
    # the -v/--verbose option which this script will use for it's 
    # own purposes
    parseCmdLine();

    # If the user specified the message, file, or targets parameter
    # then we can assume that he does not want our fancy functionality. 
    if ( defined($args{'message'}) || 
         defined($args{'file'})    ||
         defined($args{'targets'}))
    {
       exec "$svn_exec commit $commit_options";
    }
    
    $log_file = getTmpFile();

    $status_output = `$svn_exec status $status_options $file_list`;


    foreach ( split /\n/, $status_output )
    {
        my $file_path = $_;

        next if /^[?!]/; # Don't include the paths with ? or ! at the beginning

        $file_path =~ s/^.{7}//;
        $commit_candidates{"$file_path"} = $_;
        $commit_list .= $_ . "\n";
    }

    if ( "$commit_list" eq '' )
    {
        trace 0,"There is nothing to commit!\n";
        exit 0;
    }
    
    generateLogTemplate($log_file,$commit_list);

    GET_LOG_MSG:
    {
      if(callExternalEditor($log_file))
      {
          die "There was an error calling your external editor\n";
      }

      if ( 0 == parseLogFile($log_file) )
      {
          trace 0,"Your log message is empty, what would you like to do?\n";
          trace 0,"  a)bort e)dit: ";

          my $ANS = <STDIN>;

          if ($ANS =~ /^[ ]*[eE]/) 
          { 
              redo GET_LOG_MSG; 
          } else                        
          { 
              trace 0,"Not commiting any files!\n"; 
              cleanupFiles();
              exit 0;
          }
      }
    }
    
    if( (keys %commit_candidates) != () )
    {
       # Create a targets file to pass to svn commit.  This is better than including 
       # all the paths in the system call because of special character escaping issues.
       open TARGETS_FILE,">$targets_name"
           or die "Could not open $targets_name for writing: $!\n";

       foreach my $candidates (keys %commit_candidates)
       {
           print TARGETS_FILE "$candidates" . "\n"; 
       }

       close TARGETS_FILE;

       my $svn_commit_command = 
            "$svn_exec commit --targets $targets_name --file $log_file $commit_options";

       # Alright, we finally have the command line parameters, our log message, and 
       # the files we want to commit.  Commit it already!
       trace 1,"Executing the following subversion command:\n$svn_commit_command\n";
       print `$svn_commit_command`;
    } else
    {
        trace 0,"Nothing to commit!\n";
    }

    cleanupFiles();
}

Main(@_);
#------------------------------------------------------------------------------
# POD Style documentation will eventually go here!!
#------------------------------------------------------------------------------
