#!/usr/bin/perl -w

use strict;
use Getopt::Long;
Getopt::Long::Configure("bundling", "pass_through");
use File::Spec;
use File::Temp qw(tempfile tempdir);


################################################################################
# Configure Script

# Whether or not to be chatty about what we're doing.
# Set this on the command line with --verbose.
our $VERBOSE = 1;

# A horizontal rule for formatting lines.
my $ss = "--------------------------------------------------------------------";

# Whether or not to continue when we encounter a potentially serious problem.
# Set this on the command line with --live-dangerously.
our $DOUBLEOH7 = 0;

# The branches to land on.
# Set this on the command line once for each branch with --branch <name>.
our @branches;

# Convenient shorthand for --branch HEAD and --branch MOZILLA_1_8_BRANCH.
# Set these on the command line with --trunk and --moz18.
my $TRUNK;
my $MOZ18;

# The branches to land on if the user doesn't specify a branch.
my @DEFAULT_BRANCHES = qw(HEAD MOZILLA_1_8_BRANCH);

# The CVS options.  Some of these may not make sense in the context
# of this script.  Use them at your own risk.  Note that -f and -r are both
# CVS options and CVS commit options (i.e. they can go either before
# the command as general CVS options or after the commit command as different
# commit-specific options). To avoid ambiguity, you must specify
# the CVS options as --cvs-f and --cvs-r.
our $CVS_OPTION_allow_root;
our $CVS_OPTION_a;
our $CVS_OPTION_b;
our $CVS_OPTION_T;
our $CVS_OPTION_d;
our $CVS_OPTION_e;
our $CVS_OPTION_f;
our $CVS_OPTION_n;
our $CVS_OPTION_Q;
our $CVS_OPTION_q;
our $CVS_OPTION_r;
our $CVS_OPTION_s;
our $CVS_OPTION_t;
our $CVS_OPTION_v;
our $CVS_OPTION_w;
our $CVS_OPTION_x;
our $CVS_OPTION_z;

our @CVS_OPTIONS;

# The CVS commit options: -l -R -r -F <file> -f and -m.
# Some of these may not make sense in the context of this script.
# Use them at your own risk.
our $CVS_COMMIT_OPTION_l;
our $CVS_COMMIT_OPTION_R;
our $CVS_COMMIT_OPTION_r;
our $CVS_COMMIT_OPTION_F;
our $CVS_COMMIT_OPTION_f;
our $CVS_COMMIT_OPTION_m;

our @CVS_COMMIT_OPTIONS;

# Retrieve configuration from a config file, if any.  Config files are just
# regular Perl files and can override the values of all configuration variables
# declared above with "our".
my $cfg_file;
if    (-e ".xcconfig")   { $cfg_file = ".xcconfig" }
elsif (-e "~/.xcconfig") { $cfg_file = "~/.xcconfig" }
if ($cfg_file) {
    my $return = do $cfg_file;
    die "couldn't parse $cfg_file: $@" if $@;
    die "couldn't do $cfg_file: $!"    unless defined $return;
    die "couldn't run $cfg_file"       unless $return;
}

# Parse options from the command line.
GetOptions(
            # Options specific to this script.
            "verbose"               => \$VERBOSE,
            "trunk"                 => \$TRUNK,
            "moz18"                 => \$MOZ18,
            "branch=s"              => \@branches,
            "live-dangerously"      => \$DOUBLEOH7,

            # CVS options (those that go between "cvs" and "commit").
            "allow-root=s"          => \$CVS_OPTION_allow_root,
            "a"                     => \$CVS_OPTION_a,
            "b=s"                   => \$CVS_OPTION_b,
            "T=s"                   => \$CVS_OPTION_T,
            "d=s"                   => \$CVS_OPTION_d,
            "e=s"                   => \$CVS_OPTION_e,
            "cvs-f"                 => \$CVS_OPTION_f,
            "n"                     => \$CVS_OPTION_n,
            "Q"                     => \$CVS_OPTION_Q,
            "q"                     => \$CVS_OPTION_q,
            "cvs-r"                 => \$CVS_OPTION_r,
            "s"                     => \$CVS_OPTION_s,
            "t"                     => \$CVS_OPTION_t,
            "v|version"             => \$CVS_OPTION_v,
            "w"                     => \$CVS_OPTION_w,
            "x"                     => \$CVS_OPTION_x,
            "z"                     => \$CVS_OPTION_z,

            # CVS commit options (those that go after "commit").
            "l"                     => \$CVS_COMMIT_OPTION_l,
            "R"                     => \$CVS_COMMIT_OPTION_R,
            "r"                     => \$CVS_COMMIT_OPTION_r,
            "F=s"                   => \$CVS_COMMIT_OPTION_F,
            "f"                     => \$CVS_COMMIT_OPTION_f,
            "m=s"                   => \$CVS_COMMIT_OPTION_m,
          );


# The rest of the command line should be files or directories to commit.
# You can also leave it blank, in which case it'll check the current directory,
# just like "cvs commit" does.

push(@CVS_OPTIONS,
        $CVS_OPTION_allow_root ? ("--allow-root", $CVS_OPTION_allow_root) : (),
        $CVS_OPTION_a ? "-a"                   : (),
        $CVS_OPTION_b ? ("-b", $CVS_OPTION_b)  : (),
        $CVS_OPTION_T ? ("-T", $CVS_OPTION_T)  : (),
        $CVS_OPTION_d ? ("-d", $CVS_OPTION_d)  : (),
        $CVS_OPTION_e ? ("-e", $CVS_OPTION_e)  : (),
        $CVS_OPTION_f ? "-f"                   : (),
        $CVS_OPTION_n ? "-n"                   : (),
        $CVS_OPTION_Q ? "-Q"                   : (),
        $CVS_OPTION_q ? "-q"                   : (),
        $CVS_OPTION_r ? "-r"                   : (),
        $CVS_OPTION_s ? "-s"                   : (),
        $CVS_OPTION_t ? "-t"                   : (),
        $CVS_OPTION_v ? "-v"                   : (),
        $CVS_OPTION_w ? "-w"                   : (),
        $CVS_OPTION_x ? "-x"                   : (),
        $CVS_OPTION_z ? ("-z", $CVS_OPTION_z)  : (),
);

push(@CVS_COMMIT_OPTIONS,
        $CVS_COMMIT_OPTION_l ? "-l"                         : (),
        $CVS_COMMIT_OPTION_R ? "-R"                         : (),
        $CVS_COMMIT_OPTION_r ? "-r"                         : (),
        $CVS_COMMIT_OPTION_F ? ("-F", $CVS_COMMIT_OPTION_F) : (),
        $CVS_COMMIT_OPTION_f ? "-f"                         : (),
        $CVS_COMMIT_OPTION_m ? ("-m", $CVS_COMMIT_OPTION_m) : (),
);


################################################################################
# Initialize

# Duplicate the VERBOSE filehandle to STDOUT if we're being verbose;
# otherwise point it to /dev/null.
my $devnull = File::Spec->devnull();
open(VERBOSE, $VERBOSE ? ">-" : ">$devnull") or warn "Can't output verbose: $!";


################################################################################
# Get Modified Files and Current Branch

my $files = get_modified_files(\@ARGV);
if (scalar(keys(%$files)) == 0) {
    die "*** Didn't find any modified files.\n";
}
else {
    print VERBOSE "*** Modified Files:\n  " .
                  join("\n  ", sort(keys(%$files))) . "\n";
}

my $current_branch = get_current_branch($files);
print VERBOSE "*** Working Branch:\n  $current_branch\n";


################################################################################
# Get Branches to Land On

# Figure out what branches the user wants to land on.  Branches can be specified
# via "--branch <name>" or the "--trunk" and "--moz18" shortcuts.  If the user
# doesn't specify any branches, we land on the trunk and the MOZILLA_1_8_BRANCH.
push(@branches, "HEAD") if $TRUNK and !grep($_ eq "HEAD", @branches);
push(@branches, "MOZILLA_1_8_BRANCH")
  if $MOZ18 and !grep($_ eq "MOZILLA_1_8_BRANCH", @branches);
push(@branches, @DEFAULT_BRANCHES) if scalar(@branches) == 0;
print VERBOSE "*** Committing to Branches:\n  " . join("\n  ", @branches) .
              "\n";

################################################################################
# Check for Problems

# Make sure the changes apply cleanly to all branches the user wants
# to land them on.
my @conflicts;
foreach my $branch (@branches) {
    print VERBOSE "*** Checking for conflicts on $branch... ";
    foreach my $spec (sort(keys(%$files))) {
        my ($rv, $output, $errors) =
          run_cvs("update", [cvs_branch($branch), $spec], 1, 1);
        if ($rv != 0) {
            # These are spurious errors that go away once we check in
            # the removal to the working branch, so we can ignore them.
            # XXX Can we really?  Might they not also occur in other situations
            # where we shouldn't ignore them?
            if ($errors =~ m/removed $spec was modified by second party/) {
                print VERBOSE "(we can safely ignore this conflict)\n";
                next;
            }
            push(@conflicts, $branch);
        }
    }
}
if (scalar(@conflicts) > 0) {
    die "Conflicts found on " . join(", ", @conflicts) . ".\n"
        . "Please resolve them, then try your commit again.\n";
}
else {
    print VERBOSE "No conflicts found; good.\n";
}


################################################################################
# Land on Some Branch

# From now on, if we encounter an error, we should try to return the user's tree
# to its original state, so override the die handler with a function that tries
# to CVS update the tree back to the original working branch.
local $SIG{__DIE__} = sub {
    my ($message) = @_;

    print $message;
    print VERBOSE "*** Returning your tree to its original working branch... ";
    run_cvs("update", [cvs_branch($current_branch), keys(%$files)]);
    die;
};

# We gotta land somewhere once and then merge those changes into other branches.
my $land_branch;
if (grep($_ eq $current_branch, @branches)) {
    # The changes are landing on the current branch.  Groovy, let's land
    # there first.  It matters for additions and removals, I think.
    $land_branch = $current_branch;
}
else {
    # Just land on the first branch in the list.
    $land_branch = $branches[0];
    print VERBOSE "*** Switching to $land_branch... ";
    run_cvs("update", [cvs_branch($land_branch), keys(%$files)]);
}

print VERBOSE "*** Committing to $land_branch... ";
my ($rv, $output, $errors) =
  run_cvs("commit", [@CVS_COMMIT_OPTIONS, keys(%$files)]);


################################################################################
# Extract Commit Info

print VERBOSE "*** Extracting commit info.\n";
my @lines = (split/\n/, $output);
for ( my $i = 0 ; $i < scalar(@lines); $i++ ) {
    if ($lines[$i] =~ m/^(?:Checking in|Removing) (.*);$/) {
        my $spec = $1;
        print VERBOSE "  $spec: ";
        my $entry = $files->{$spec};
        $entry or die " not on the list of files committed!\n";
        $i += 2;
        $lines[$i] =~ m/^(initial|new)\srevision:\s
                        ([\d\.]+|delete)(?:;\s
                        previous\srevision:\s
                        ([\d\.]+))?$/x;
        if ($1 eq "new") {
            print VERBOSE "$3 -> $2.\n";
            $entry->{new_rev} = $2 eq "delete" ? "" : $2;
            $entry->{old_rev} = $3;
        }
        elsif ($1 eq "initial") {
            print VERBOSE "new file -> $2.\n";
            $entry->{new_rev} = $2;
            $entry->{old_rev} = "";
        }
        else {
            die "can't figure out its old and new revisions!\n";
        }
    }
}


################################################################################
# Check In to Other Branches

foreach my $branch (@branches) {
    next if $branch eq $land_branch;

    foreach my $spec (sort(keys(%$files))) {
        my $entry = $files->{$spec};

        if ($entry->{old_rev} && $entry->{new_rev}) {
            print VERBOSE "*** Merging $spec changes from $entry->{old_rev} " .
                          "to $entry->{new_rev} into $branch... ";
            run_cvs("update", [cvs_branch($branch), "-j", $entry->{old_rev},
                               "-j", $entry->{new_rev}, $spec]);
        }
        elsif ($entry->{old_rev}) {
            print VERBOSE "*** Removing $spec on $branch... ";
            # CVS doesn't tag removed files with a new revision number,
            # so we merge from the old revision to the branch itself.
            run_cvs("update", [cvs_branch($branch), "-j", $entry->{old_rev},
                               "-j", $land_branch, $spec]);
        }
        elsif ($entry->{new_rev}) {
            print VERBOSE "*** Adding $spec on $branch... ";
            run_cvs("update", [cvs_branch($branch), "-j", $entry->{new_rev},
                               $spec]);
        }

        print VERBOSE "*** Committing $spec on $branch... ";
        run_cvs("commit", [@CVS_COMMIT_OPTIONS, $spec]);
    }
}

print VERBOSE "*** Returning your tree to its original working branch... ";
run_cvs("update", [cvs_branch($current_branch), keys(%$files)]);

################################################################################
# Utility Functions

# Returns a hash of modified files indexed by file spec.
sub get_modified_files {
    my ($args) = @_;

    # We figure out which files are modified by running "cvs update"
    # and grepping for /^(M|A) /.  We run the command in dry run mode so we
    # don't actually update the files in the process.
    # XXX perhaps we should update them, since we won't be able to commit them
    # if they aren't up-to-date; on the other hand, CVS makes you update them
    # manually rather than automatically upon commit, so perhaps there's method
    # to its madness.

    print VERBOSE "*** Looking for modified files... ";
    my ($rv, $output, $errors) = run_cvs("update", $args, 1);
    # Break the output into lines and filter for lines about changes.
    my @lines = grep(m/^(M|A|R) /, split(/\n/, $output));
    my %files;
    foreach my $line (@lines) {
        $line =~ m/^(M|A|R) (.*)/;
        $files{$2} = get_cvs_file($2);
        $files{$2}->{change_type} = $1;
    }
    return \%files;
}

# Given a file spec, returns a hash of information about the file extracted
# from the CVS/Entries file.
sub get_cvs_file {
    my ($spec) = @_;
    my ($volume, $directories, $filename) = File::Spec->splitpath($spec);
    my $cvsdir = $directories ? File::Spec->catdir($directories, "CVS") : "CVS";
    my $files = File::Spec->catpath($volume, $cvsdir, "Entries");
    open(ENTRIES, "<", $files)
      or die "Can't read entries file $files for file $spec: $!";
    while (<ENTRIES>) {
        my ($name, $revision, $timestamp, $conflict, $options, $tagdate) =
          ($_ =~ m|/([^/]*)    # filename
                   /([^/]*)    # revision
                   /([^/+]*)   # timestamp
                   (\+[^/]*)?  # (optional) conflict
                   /([^/]*)    # options
                   /([^/]*)    # tag/date
                  |x);
        next if $name ne $filename;
        close ENTRIES;
        return { name => $name, revision => $revision, conflict => $conflict,
                 options => $options, tagdate => $tagdate };
    }
    die "Couldn't find entry for file $spec in entries file $files.";
}

# Given a set of files, extracts their current working branch, testing for
# multiple branches and date-based tags in the process.
sub get_current_branch {
    my ($files) = @_;
    my %branches;
    foreach my $filename (keys %$files) {
        my $entry = $files->{$filename};
        $entry->{tagdate} =~ m/^(T|D)?(.*)/;
        if    ($1 and $1 eq "D") { warn "$filename checked out by date $1\n" }
        elsif ($2 eq "")         { $branches{"HEAD"}++ }
        else                     { $branches{$2}++ }
        if (scalar(keys(%branches)) > 1 && !$DOUBLEOH7) {
            die("Modified files checked out from multiple branches:\n  "
                . join("\n  ", map("$_: $files->{$_}->{tagdate}",
                                   sort(keys(%$files))))
                . "Sounds scary, so I'm stopping.  Want me to continue?\n"
                . "Run me again with --live-dangerously and tell my authors\n"
                . "how it went.\n");
        }
    }
    return (keys(%branches))[0];
}

# Runs a CVS command and outputs the results.  Runs the command in dry run mode
# if dry run is enabled globally ($DRY_RUN) or for this specific function call;
# and dies on error by default, but can be set to merely warn on error.
# Returns the return value of the CVS command, its output, and its errors.
sub run_cvs {
    my ($cmd, $args, $dry_run, $warn_on_err) = @_;
    # Let callers override dry run setting, since certain information gathering
    # routines always run in dry run mode no matter what the global setting is.

    my ($rv, $output, $errors) =
      system_capture("cvs",
                     @CVS_OPTIONS,
                     $dry_run && !$CVS_OPTION_n ? "-n" : (),
                     $cmd,
                     @$args);
    if ($rv != 0) {
        if (!$warn_on_err) {
            die "\n$errors\n$ss\n";
        }
        warn "\n$errors\n$ss\n"
    }
    else {
        print VERBOSE "\n$output\n$ss\n";
    }
    return ($rv, $output, $errors);
}

# Returns the appropriate CVS command line argument for specifying a branch.
# Usually this is -r <branch name>, but if we're dealing with the special HEAD
# branch it's -A instead.
sub cvs_branch {
    my ($branch) = @_;
    return $branch eq "HEAD" ? "-A" : ("-r", $branch);
}

# Runs a command and captures its output and errors.
# Returns the command's exit code, output, and errors.
sub system_capture {
    # XXX This should be using in-memory files, but they require that we close
    # STDOUT and STDERR before reopening them on the in-memory files, and doing
    # that on STDERR causes CVS to choke with return value 256.
  
    my ($command, @args) = @_;
  
    my ($rv, $output, $errors);
  
    # Back up the original STDOUT and STDERR so we can restore them later.
    open(OLDOUT, ">&STDOUT") or die "Can't back up STDOUT to OLDOUT: $!";
    open(OLDERR, ">&STDERR") or die "Can't back up STDERR to OLDERR: $!";
    use vars qw( *OLDOUT *OLDERR ); # suppress "used only once" warnings
  
    # Close and reopen STDOUT and STDERR to in-memory files, which are just
    # scalars that take output and append it to their value.
    # XXX Disabled in-memory files in favor of temp files until in-memory issues
    # can be worked out.
    #close(STDOUT);
    #close(STDERR);
    #open(STDOUT, ">", \$output) or die "Can't open STDOUT to output var: $!";
    #open(STDERR, ">", \$errors) or die "Can't open STDERR to errors var: $!";
    my $outfile = tempfile();
    my $errfile = tempfile();
    # Perl 5.6.1 filehandle duplication doesn't support the three-argument form
    # of open, so we can't just open(STDOUT, ">&", $outfile); instead we have to
    # create an alias OUTFILE and then do open(STDOUT, ">&OUTFILE").
    local *OUTFILE = *$outfile;
    local *ERRFILE = *$errfile;
    use vars qw( *OUTFILE *ERRFILE ); # suppress "used only once" warnings
    open(STDOUT, ">&OUTFILE") or open(STDOUT, ">&OLDOUT")
                                 and die "Can't dupe STDOUT to output file: $!";
    open(STDERR, ">&ERRFILE") or open(STDOUT, ">&OLDOUT")
                                 and open(STDERR, ">&OLDERR")
                                 and die "Can't dupe STDERR to errors file: $!";
  
    # Run the command.
    print VERBOSE "$command " . join(" ", @args) . "\n";
    $rv = system($command, @args);
  
    # Grab output and errors from the temp files.  In a block to localize $/.
    # XXX None of this would be necessary if in-memory files was working.
    {
        local $/ = undef;
        seek($outfile, 0, 0);
        seek($errfile, 0, 0);
        $output = <$outfile>;
        $errors = <$errfile>;
    }

    # Restore original STDOUT and STDERR.
    close(STDOUT);
    close(STDERR);
    open(STDOUT, ">&OLDOUT") or die "Can't restore STDOUT from OLDOUT: $!";
    open(STDERR, ">&OLDERR") or die "Can't restore STDERR from OLDERR: $!";

    return ($rv, $output, $errors);
}
