[Development] [FYI] new git-gpush features, a.k.a. the smart way of pushing to gerrit

Oswald Buddenhagen oswald.buddenhagen at digia.com
Wed Oct 22 10:32:11 CEST 2014


we are in the middle of the bugfixing week, so everyone is wrangling
half a dozen mostly unrelated changes at the same time. the perfect
time to plug the awesomely improved gpush!

from the manual:

    Prior to actually pushing any commits, gpush will temporarily rebase
    them onto a new base. This has the advantage that you can keep many
    unrelated "series" in your local branch without creating spurious
    dependencies on Gerrit, effectively pretending that you have a separate
    branch for every series. Furthermore, gpush will keep the base of
    subsequent pushes of the same series constant (unless told otherwise),
    which means that you can closely track the upstream branch without
    pushing needless rebases to Gerrit (and thus breaking inter-patchset
    diffs).

exactly the thing you need right now, huh?

quickstart:

# RTFM
$ git gpush -h
$ git gpick -h
$ git gpull -h

# get all your pending changes on the current branch, in case you don't
# do this anyway:
$ git cherry-pick ...

# bootstrap the thing:
$ git gpick --check    

# actually push two changes. yup, you can use change-ids:
$ git gpush I435fa988e: -2

# re-push the series ending at HEAD:
$ git gpush
# (now, that was easy ^^)

# get stuff from somebody else:
$ git gpick +I987ac23f1
# no need to worry about accidentally re-pushing it any more!

# update your local copy after somebody has been messing with your
# change (gpush will tell you about it, so you won't overwrite the
# changes accidentally):
$ git gpick I9ce6d9e7a3

# pull/rebase your local series:
$ git gpull

magic!
you only need to save the attached three scripts (simply overwrite your
local checkout of qtrepotools for the time being) to get started.

this stuff is not tested under windows, but there shouldn't be any major
problems. just make sure that you have ActivePerl *before* msys perl in
your PATH (i have a hunch that it won't work otherwise ^^).

feel free to complain if something doesn't work for you or you are
missing some important feature.
if you hate the very idea of the dynamic rebasing, put your fatwa on
thiago, as it was his idea. though i don't think he will recognize much
of his code. :D

oh, btw, i'm looking for reviewers for the whole thing. don't worry -
it's less than 2500 LOC of perl spread over 30 changes.
get started here: https://codereview.qt-project.org/89097


on a completely unrelated note, we deployed a bugfixed version of
gerrit. the most visible change is the fixed keyboard navigation in the
one-page diff view. the other fixes happened mostly behind the scenes.
one consequence of the upgrade is that i can (and will) actually run the
long promised auto-abandoning of de-facto abandoned changes quite
soonish.
-------------- next part --------------
#!/usr/bin/env perl
# Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
# Copyright (C) 2014 Intel Corporation.
# Contact: http://www.qt-project.org/legal
#
# You may use this file under the terms of the 3-clause BSD license.
# See the file LICENSE from this package for details.
#

use strict;
use warnings;
no warnings qw(io);

use Carp;
$SIG{__WARN__} = \&Carp::cluck;

use List::Util qw(first);
use File::Basename;
use File::Temp qw(mktemp);
use IPC::Open2 qw(open2);
use JSON;

# Cannot use Pod::Usage for this file, since git on Windows will invoke its own perl version, which
# may not (msysgit for example) support this module, even if it's considered a Core module.
sub usage
{
    print << "EOM";
Usage:
    git gpush [opts] [[sha1/ref-from]:[ref-to][:base]] [-<count>] [+<reviewer>] [=<CC user>] [-- <push opts>]

    Pushes changes to Gerrit and adds reviewers and CC to the patch
    sets

Description:
    This script is used to push patch sets to Gerrit, and at the same
    time add reviewers and CCs to the patch sets pushed.

    You can use email addresses, Gerrit usernames or aliases for the
    name of the reviewers/CCs.

    If no sha1 or ref-from is specified, 'HEAD' is used.

    If no ref-to is specified, the remote tracking branch for 'ref-from'
    is used as
        'refs/for/<remote tracking branch>'.

    Unless --simple is specified, gpush will operate in "smart" mode
    (see below).

    If no remote is specified or configured, 'gerrit' is used. You may
    configure a remote like this:
        git config gpush.remote <remote name>

    If all the options above have been populated, the remainder
    options are passed on directly to the normal 'git push' command.
    If you want to avoid specifying all options first, any options
    specified after a '--' are also passed on directly to the
    underlying 'git push' command.

Options:
    -a, --append
        Append new commits to the series right in front of them.

    -d, --draft
        Mark the pushed changes as drafts. This switch is usually
        unnecessary, as gpush will recognize WIP changes by subject.

    -p, --publish
        Do not mark the pushed changes as drafts even if they have
        WIP markers.

    -s, --simple
        Push without smart rebasing. This is necessary for merges.

    -f, --force
        Push despite newer PatchSets being on Gerrit.

    --rebase
        Reset the base of a previously pushed series to the current
        upstream branch head.

    -r, --remote
        Specify the git remote to push to. The default is 'gerrit'.

    --aliases
        Report all registered aliases and quit.

    -n, --dry-run
        Do everything except actually pushing any commits.

    -v, --verbose
        Show the resolved aliases, SHA1s of commits, and other information.

    -q, --quiet
        Suppress the usual output about what is pushed where.

    --debug
        Print debug information.

Smart Mode:
    Prior to actually pushing any commits, gpush will temporarily rebase
    them onto a new base. This has the advantage that you can keep many
    unrelated "series" in your local branch without creating spurious
    dependencies on Gerrit, effectively pretending that you have a separate
    branch for every series. Furthermore, gpush will keep the base of
    subsequent pushes of the same series constant (unless told otherwise),
    which means that you can closely track the upstream branch without
    pushing needless rebases to Gerrit (and thus breaking inter-patchset
    diffs).

    When pushing a series for the first time, the exact range of commits
    needs to be specified, either as <base>..<tip> or as <tip> -<count>.
    Subsequent pushes of the same series need only the tip. New commits
    in the middle or right in front of the series will be automatically
    "absorbed"; to add new commits at the end of a series, use --append.
    It is possible to regroup series any time by specifying new exact
    ranges (make sure to push all affected series before you use the
    tip-only syntax again).

    Unless specified otherwise, the first push of a series will be based
    on the head of the upstream branch. It is possible to specify arbitrary
    other commits already known to Gerrit, including still pending patchsets.
    If you need to change the base of a subsequent push (due to a conflicted
    rebase), use the --rebase option.

Configuring Aliases:
    Aliases are read from the
        .git-gpush-aliases
    located next to the script, then from the git config which may
    have aliases set either locally in the current repository,
    globally (in your ~/.gitconfig), or system-wide.

    You can add aliases to your global git config like this:
        git config --global gpush.alias.<alias key> <alias value>
    and if you only want it to be local to the current repository,
    just drop the --global option.
    Note that git config keys are constrained regarding allowed
    characters, so it is impossible to map some IRC nicks via git
    configuration.

    An alias may contain multiple comma-separated email addresses;
    for example, to set a single alias for an entire team.

    Inside .git-gpush-aliases, each alias may also be a comma-separated
    list, in case a user uses multiple handles.

Copyright:
    Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
    Copyright (C) 2014 Intel Corporation.
    Contact: http://www.qt-project.org/legal

License:
    You may use this file under the terms of the 3-clause BSD license.
EOM
}

use constant {
    FORCE => 'FORCE',
    MODIFIED => 'MODIFIED',
    UNMODIFIED  => 'UNMODIFIED',
    OUTDATED => 'OUTDATED',
    MERGED => 'MERGED'
};

my $debug = 0;
my $verbose = 0;
my $quiet = 0;
my $dry_run = 0;

my $remote;
my $remote_override = 0;
my $gerrit_ssh;
my $gerrit_project;
my $ref_from_base;
my $ref_from = "HEAD";
my $commit_count = 0;
my $origin;
my $ref_to = "";
my $ref_base = "";
my $ref_override = 0;
my $append = 0;
my $rebase = 0;
my $force = 0;
my $draft = 0;
my $simple = 0;

my %aliases = ();

my @reviewers = ();
my @CCs = ();

my @arguments = ();

my %gitconfig = ();

my %commit2parent = ();
my %commit2change = ();
my %commit2subject = ();
my %commit2message = ();
my %commit2tree = ();
my %commit2author = ();
my %commit2committer = ();
my %commit2orig = ();
my %commit2freshness = ();
my %commit2draftness = ();

my %change2local = ();
my %change2pushed = ();
my %change2orig = ();
my %change2base = ();
my %change2current = ();
my %change2status = ();
my %knownsha1s = ();

sub format_cmd(@)
{
    return join(' ', map { /\s/ ? '"' . $_ . '"' : $_ } @_);
}

sub close_pipe($;$)
{
    close(shift) and return 1;
    die("Closing pipe failed: $!\n") if ($!);
    if ($? & 128) {
        die("Process crashed with signal $?.\n") if ($? != 141); # allow SIGPIPE
        $? = 0;
    } elsif ($? && shift) {
        exit($? >> 8);
    }
    return 0;
}

sub open_cmd_pipe(@)
{
    print "+ ".format_cmd(@_)."\n" if ($debug);
    open(my $pipe, '-|', @_) or die("Failed to run \"$_\": $!\n");
    return $pipe;
}

sub read_cmd_line(@)
{
    my $pipe = open_cmd_pipe(@_);
    my $line = <$pipe>;
    if (defined($line)) {
        chomp $line ;
        print "- $line\n" if ($debug);
    }
    close_pipe($pipe);
    return $line;
}

sub open_git_pipe(@)
{
    return open_cmd_pipe('git', @_);
}

sub read_git_line(@)
{
    return read_cmd_line('git', @_);
}

sub parse_arguments(@)
{
    while (scalar @_) {
        my $arg = shift @_;

        if ($arg eq "-v" || $arg eq "--verbose") {
            $verbose = 1;
        } elsif ($arg eq "-q" || $arg eq "--quiet") {
            $quiet = 1;
        } elsif ($arg eq "--debug") {
            $debug = 1;
            $verbose = 1;
        } elsif ($arg eq "-n" || $arg eq "--dry-run") {
            $dry_run = 1;
        } elsif ($arg eq "-d" || $arg eq "--draft") {
            $draft = 1;
        } elsif ($arg eq "-p" || $arg eq "--publish") {
            $draft = -1;
        } elsif ($arg eq "-f" || $arg eq "--force") {
            $force = 1;
        } elsif ($arg eq "-a" || $arg eq "--append") {
            $append = 1;
        } elsif ($arg eq "--rebase") {
            $rebase = 1;
        } elsif ($arg eq "-r" || $arg eq "--remote") {
            die("--remote needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $remote = shift @_;
        } elsif ($arg eq "-s" || $arg eq "--simple") {
            $simple = 1;
        } elsif ($arg eq "--aliases") {
            foreach my $key (sort(keys %aliases)) {
                print "$key = $aliases{$key}\n";
            }
            exit 0;
        } elsif ($arg eq "-?" || $arg eq "--?" || $arg eq "-h" || $arg eq "--help") {
            usage();
            exit 0;
        } elsif ($arg eq "--") {
            push @arguments, @_;
            return;
        } elsif ($arg =~ /^\+(.+)/) {
            push @reviewers, split(/,/, lookup_alias($1));
        } elsif ($arg =~ /^\=(.+)/) {
            push @CCs, split(/,/, lookup_alias($1));
        } elsif ($arg =~ /^\-(\d+)/) {
            $commit_count = $1;
        } elsif ($arg =~ /^\-(.+)/) {
            push @arguments, $arg;
        } elsif (!$remote_override || !$ref_override) {
            if ($arg =~ /([^:]*):([^:]*)(?::([^:]*))?/) {
                $ref_from = $1 if (defined $1 && $1 ne "");
                $ref_to = $2 if (defined $2 && $2 ne "");
                $ref_base = $3 if (defined $3 && $3 ne "");
                $ref_override = 1;
            } else {
                print STDERR "Warning: Specifying a bare remote is deprecated.".
                             " Use --remote instead.\n";
                $remote = $arg;
                $remote_override = 1;
            }
        } else {
            push @arguments, $arg;
        }
    }

    die("--quiet and --verbose/--debug are mutually exclusive.\n")
        if ($quiet && $verbose);

    die("Specifying a base and --rebase are mutually exclusive.\n")
        if (length($ref_base) && $rebase);

    if ($ref_from =~ /^(.*)\.\.(.*)$/) {
        ($ref_from_base, $ref_from) = ($1, $2);
        die("Specifying a commit count and a range are mutually exclusive.\n") if ($commit_count);
    }

    die("Specifying --simple and a range or commit count are mutually exclusive.\n")
        if ($simple && ($commit_count || defined($ref_from_base)));

    die("Specifying --simple and --append are mutually exclusive.\n")
        if ($simple && $append);

    die("Specifying --simple and a base or --rebase are mutually exclusive.\n")
        if ($simple && (length($ref_base) || $rebase));

    if ($ref_to =~ s,^refs/for/,,) {
        die "Pushing to refs/for/ is inconsistent with the --draft option.\n" if ($draft > 0);
        print STDERR "Notice: it is unnecessary to specify refs/for/ in the target ref.\n"
            if (!$quiet);
    } elsif ($ref_to =~ s,^refs/drafts/,,) {
        die "Pushing to refs/drafts/ is inconsistent with the --publish option.\n" if ($draft < 0);
        if ($draft) {
            print STDERR "Notice: it is unnecessary to specify refs/drafts/ in the target ref.\n"
                if (!$quiet);
        } else {
            print STDERR "Notice: prefer the --draft option over specifying refs/drafts/ in the target ref.\n"
                if (!$quiet);
            $draft = 1;
        }
    }
}

sub fileContents($)
{
    my ($filename) = @_;

    my @contents = "";
    my $fh;
    if (-e $filename && open($fh, "< $filename")) {
        @contents = <$fh>;
        close $fh;
    }
    return @contents;
}

sub git_configs($)
{
    my ($key) = @_;
    my $ref = $gitconfig{$key};
    return defined($ref) ? @$ref : ();
}

sub git_config($;$)
{
    my ($key, $dflt) = @_;
    my @cfg = git_configs($key);
    return scalar(@cfg) ? $cfg[-1] : $dflt;
}

sub load_config()
{
    my $script_path = dirname($0);

    # Read aliases from .git-gpush-aliases file
    foreach my $line (fileContents("$script_path/.git-gpush-aliases")) {
        chomp $line;
        $line =~ s,(#|//).*$,,;             # Remove any comments
        if ($line =~ /([^ ]+)\s*=\s*(\S+)/) {  # Capture the alias
            for my $alias (split(/,/, $1)) {
                $aliases{$alias} = $2;
            }
        }
    }

    # Read all git configuration at once, as that's faster than repeated
    # git invocations, especially under Windows.
    foreach (`git config --list`) {
        die("Malformed git config output '$_'.\n") if (!/^([^=]+)=(.*)$/);
        push @{$gitconfig{$1}}, $2;
    }

    $remote = git_config('gpush.remote', 'gerrit');
    die("Configuring ref-from is not supported any more.\n") if (git_config('gpush.ref-from'));
    die("Configuring ref-to is not supported any more.\n") if (git_config('gpush.ref-to'));
    foreach (keys %gitconfig) {
        if (/^gpush\.alias\.(.*)$/) {
            $aliases{$1} = git_config($_);
        }
    }
}

sub lookup_alias($)
{
    my ($user) = @_;

    my $alias = $aliases{$user};
    if (defined $alias && $alias ne "") {
        print "Resolved $user to $alias.\n" if ($verbose);
        return $alias;
    }

    return $user;
}

sub add_reviewers()
{
    if (@reviewers || @CCs) {
        my @dudes = ();
        push @dudes, "--receive-pack=git receive-pack";
        push @dudes, map { " --reviewer=$_" } @reviewers;
        push @dudes, map { " --cc=$_" } @CCs;
        push @arguments, join('', @dudes); # Single argument to git push
    }
}

sub goto_gitdir()
{
    my $cdup = read_git_line('rev-parse', '--show-cdup');
    exit $? >> 8 if $?;
    chdir($cdup) unless $cdup eq "";
}

sub determine_target($)
{
    my ($source) = @_;

    # First try to extract a branch name directly.
    my $ref = $source;
    $ref =~ s/[~^].*$//;
    if ($ref eq "HEAD") {
        my $sref = read_git_line("symbolic-ref", "-q", "HEAD");
        $ref = $sref if ($? == 0);
    }
    $ref =~ s,^refs/heads/,,;
    read_git_line("rev-parse", "--verify", "-q", "refs/heads/".$ref);
    if ($? != 0) {
        # Next, try to deduce a branch from the commit.
        my $curbranch;
        my @otherbranches = ();
        my $branches = open_git_pipe("branch", "--contains", $source);
        while (<$branches>) {
            print "- $_" if ($debug);
            if (/^\* \(/) {
                # New git versions will tell us the currently rebased branch.
                if (/^\* \(no branch, rebasing (.*)\)$/ && $1 ne "(null)") {
                    $curbranch = $1;
                }
                last;
            } elsif (/^\* (.*)$/) {
                $curbranch = $1;
                last;
            } elsif (/^  (.*)$/) {
                push @otherbranches, $1;
            }
        }
        close_pipe($branches, 1);
        if (!defined($curbranch)) {
            # If the commit is not on the current branch, see if it is on _one_
            # other branch with a tracking branch.
            my @goodbranches = ();
            foreach my $other (@otherbranches) {
                push @goodbranches, $other if (defined(git_config("branch.$other.merge")));
            }
            $curbranch = $goodbranches[0] if (@goodbranches == 1);
        }
        if (!defined($curbranch)) {
            die("Cannot deduce source branch for $ref.\n") if ($ref_to eq "");
            $ref = $source;   # For the origin notice below.
        } else {
            $ref = $curbranch;
        }
    }
    if ($ref_to eq "") {
        $ref_to = git_config("branch.$ref.merge");
        die "$ref has no tracking branch.\n" if (!defined($ref_to));
        $ref_to =~ s,^refs/heads/,,;
    }
    $origin = git_config("branch.$ref.remote");
    if (!defined($origin)) {
        $origin = git_config('gpush.origin', 'origin');
        print STDERR "Notice: $ref has no upstream remote; defaulting to $origin.\n" if (!$quiet);
    }
}

sub format_change($$)
{
    my ($id, $subject) = @_;
    $subject =~ s/^(.{50}).{5,}$/$1\[...]/;
    return substr($id, 0, 10)." ($subject)";
}

sub format_commit($)
{
    my ($commit) = @_;
    return format_change($commit2change{$commit}, $commit2subject{$commit});
}

sub query_pushes()
{
    my $refs = open_git_pipe('show-ref');  # No way to filter the namespace :(
    while (<$refs>) {
        print "- $_" if ($debug);
        chop;
        if (/^(.{40}) refs\/gpush\/\Q$ref_to\E\/([^_]+)_(.*)$/o) {
            if ($3 eq 'pushed') {
                $change2pushed{$2} = $1;
            } elsif ($3 eq 'base') {
                $change2base{$2} = $1;
            } elsif ($3 eq 'orig') {
                $change2orig{$2} = $1;
            }
        }
    }
    close_pipe($refs, 1);
}

sub full_changeid($$)
{
    my ($id, $candidates) = @_;

    return $id if (defined($$candidates{$id}));

    my @changes = ();
    my $rx = qr/^\Q$id\E/;
    foreach my $change (keys %{$candidates}) {
        push @changes, $change if ($change =~ $rx);
    }
    return undef if (!@changes);
    return $changes[0] if (@changes == 1);
    die("$id is ambiguous.\n");
}

sub full_local_changeid($)
{
    return full_changeid(shift, \%change2local);
}

sub read_fields($@)
{
    my $fh = shift;
    return 0 if (eof($fh));
    local $/ = "\0";
    for (@_) { chop($_ = <$fh>); }
    return 1;
}

use constant GIT_LOG_ARGS =>
        ('--first-parent',
         '-z', '--pretty=%H%x00%P%x00%T%x00%B%x00%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd');

sub process_log($)
{
    my ($log) = @_;
    my @commits = ();

    my @author = (undef, undef, undef);
    my @committer = (undef, undef, undef);
    while (read_fields($log, my ($commit, $parents, $tree, $message), @author, @committer)) {
        $message =~ /^(.*)$/m;
        my $subject = $1;

        die(format_change($commit, $subject)." has no Change-Id.\n")
            if ($message !~ /^Change-Id: (.+)$/m);
        my $changeid = $1;

        print "-- $commit: ".format_change($changeid, $subject)."\n" if ($debug);

        $commit2parent{$commit} = $parents;
        $commit2change{$commit} = $changeid;
        $commit2subject{$commit} = $subject;
        $commit2message{$commit} = $message;
        $commit2tree{$commit} = $tree;
        $commit2author{$commit} = [ @author ];
        $commit2committer{$commit} = [ @committer ];
        unshift @commits, $commit;
    }
    return @commits;
}

sub walk_pushes(@)
{
    my (@tips) = @_;

    return if (!@tips);

    my @gitcmd = ('git', 'log', GIT_LOG_ARGS, '--stdin', "^$origin/$ref_to");
    print "+ @gitcmd\n" if ($debug);
    my ($to_child, $log);
    my $pid = open2($log, $to_child, @gitcmd) or die("Failed to run \"git\": $!\n");
    local $SIG{PIPE} = "IGNORE";
    foreach (@tips) {
        print "> $_\n" if ($debug);
        print $to_child "$_\n";
    }
    close($to_child) or die("Closing STDIN pipe failed: $!\n");
    process_log($log);
    close($log);
    waitpid($pid, 0) > 0 or die("waitpid() failed: $!\n");
    exit($? >> 8) if ($?);
}

sub walk_all_pushes()
{
    # This queries potentially *way* too many commits. The assumption
    # is that this is still a lot cheaper than spawning a new process
    # for every commit, in particular under Windows.
    walk_pushes(values %change2pushed);
}

sub walk_selected_pushes(@)
{
    my (@tips) = @_;

    my @pushed = ();
    foreach my $tip (@tips) {
        my $pcommit = $change2pushed{$commit2change{$tip}};
        last if (!defined($pcommit));
        push @pushed, $pcommit;
    }
    walk_pushes(@pushed);
}

sub get_pushed($)
{
    my ($commit) = @_;
    my @commits = ();

    my $base = $change2base{$commit2change{$commit}};
    while (1) {
        my $parent = $commit2parent{$commit};
        last if (!defined($parent));
        unshift @commits, $commit;
        # We stop at the base, so it's possible to base a series on another
        # pending series even if it is directly adjacent.
        last if (defined($base) && $base eq $parent);
        $commit = $parent;
    }
    return @commits;
}

sub walk_revs(@)
{
    my $log = open_git_pipe('log', GIT_LOG_ARGS, @_);
    my @commits = process_log($log);
    close_pipe($log, 1);
    return @commits;
}

sub parse_rev($)
{
    my ($rev) = @_;

    my $out = read_git_line('rev-parse', '--verify', '-q', $rev);
    if (!$out) {
        die("$rev is not a valid revspec.\n") if ($rev !~ /^(\w+)(.*)$/);
        my ($id, $rest) = ($1, $2);
        if (!%change2local) {
            determine_target('HEAD');
            query_pushes();
            foreach my $commit (walk_revs("$origin/$ref_to..")) {
                $change2local{$commit2change{$commit}} = $commit;
            }
        }
        my $changeid = full_local_changeid($id);
        die("$id does not refer to a revision in the local branch.\n")
            if (!defined($changeid));
        my $commit = $change2local{$changeid};
        return $commit if (!$rest);
        $out = read_git_line('rev-parse', '--verify', '-q', $commit.$rest);
        die("$rev is not a valid revspec.\n") if (!$out);
    }
    return $out;
}

sub determine_series(@)
{
    my (@proto_commits) = @_;
    my @commits = ();

    my %wanted_ids = ();
    my $all_new = 1;
    while (@proto_commits) {
        my $commit = pop @proto_commits;
        my $changeid = $commit2change{$commit};
        my $pushed = $change2pushed{$changeid};
        if (!defined($pushed)) {
            # Yet unpushed changes are always absorbed into the series.
            print "Absorbing new $changeid\n" if ($debug);
        } else {
            # If no precise range was specified, we need to deduce where the series ends.

            # First we find all other parent changes which were part of the push.
            my @pchanges = map { $commit2change{$_} } get_pushed($pushed);

            # Then we check whether the change itself or any change in its ancestor chain
            # is referenced by the already assembled proto-series or any of its ancestors.
            # We must look at ancestors both ways, as otherwise re-ordering changes would
            # break the series. Obviously, the tip must be accepted without question.
            if (@commits) {
                if ((!$append || !$all_new)
                    && !defined(first { defined($wanted_ids{$_}) } @pchanges)) {
                    # Miss; end of series.
                    push @proto_commits, $commit;
                    print "Breaking off at $changeid\n" if ($debug);
                    last;
                }
                # Hit; add the change to the series.
                print "Adding seen $changeid\n" if ($debug);
            } else {
                print "Adding seen $changeid as tip\n" if ($debug);
            }
            $wanted_ids{$_} = 1 foreach (@pchanges);

            $all_new = 0;
        }

        unshift @commits, $commit;
    }
    if ($all_new) {
        print STDERR "Attempted to push ".scalar(@commits)." new change(s):\n";
        print STDERR "[ Append: ".format_commit($proto_commits[-1])." ]\n" if (@proto_commits);
        print STDERR "  ".format_commit($_)."\n" foreach (@commits);
        die("Please ".(@proto_commits ? "use --append or " : "")
            ."specify exact ranges for particular series.\n");
    }
    return @commits;
}

sub get_patches()
{
    my @commits = ();

    my $tip = parse_rev($ref_from);
    my $base = defined($ref_from_base) ? parse_rev($ref_from_base) : undef;
    if (!%change2local) {
        determine_target($ref_from);
        query_pushes();
        my @args;
        push @args, -$commit_count if ($commit_count);
        push @args, $ref_from;
        push @args, "^$ref_from_base" if (defined($ref_from_base));
        push @args, "^$origin/$ref_to";
        @commits = walk_revs(@args);
    } else {
        if (defined($ref_from_base)) {
            while ($tip ne $base) {
                unshift @commits, $tip;
                $tip = $commit2parent{$tip};
                last if (!defined($tip));
            }
        } else {
            for (my $count = $commit_count ? $commit_count : -1; --$count != -1; ) {
                my $parent = $commit2parent{$tip};
                last if (!defined($parent));
                unshift @commits, $tip;
                $tip = $parent;
            }
        }
    }
    die("Specified commit range is empty.\n") if (!@commits);
    return @commits;
}

sub determine_base(@)
{
    my @commits = @_;

    if (!length($ref_base) && !$rebase) {
        # We have no base yet, so deduce it from the previous push(es).
        # The simple approach would be using the merge-base(s) of the previous push(es)
        # and upstream. However, that would not work if we based the series on another
        # pending series.
        # The next idea is walking the ancestor chain of each change until we encounter
        # the first change which is not part of this series. But this will be confused
        # by changes dropped from (the bottom of) the series.
        # That means that we need to record the base of each push in a ref.
        my %bases = map {
            my $base = $change2base{$commit2change{$_}};
            defined($base) ? ($base => 1) : ();
        } @commits;
        if (keys %bases > 1) {
            print "Pushing ".scalar(@commits)." commit(s) for $ref_to on $remote:\n";
            foreach my $commit (@commits) {
                my $changeid = $commit2change{$commit};
                my $chstr = format_change($changeid, $commit2subject{$commit});
                my $base = $change2base{$changeid};
                $chstr .= (' ' x (69 - length($chstr))).'@'.substr($base, 0, 8)
                    if (defined($base));
                print "  ".$chstr."\n";
            }
            die("Changes were previously pushed with mixed bases. Please specify a base.\n");
        }
        if (%bases) {
            print "Re-using base from previuos push of (some of) these changes.\n" if ($verbose);
            return (keys %bases)[0];
        }
    }

    if (!length($ref_base)) {
        $ref_base = "$origin/$ref_to";
        print "Using upstream $ref_base as base.\n" if ($verbose);
    }
    my $base = read_git_line("rev-parse", $ref_base);
    if ($?) {
        print STDERR "... while trying to determine base commit.\n";
        exit($? >> 8);
    }
    return $base;
}

sub get_omitted($)
{
    my ($commit) = @_;

    my $pushed = $change2pushed{$commit2change{$commit}};
    return () if (!defined($pushed));
    my @old_commits = get_pushed($pushed);
    pop @old_commits;
    return @old_commits;
}

sub advance_base($@)
{
    my ($base, @pushed) = @_;

    # If changes from the previous push were already merged and the local
    # series was subsequently rebased, it would be necessary to rebase the
    # push to make the remaining commits still apply. To avoid the churn
    # resulting from this, we simply prepend the already merged commits to
    # the push.
    while (@pushed) {
        my $commit = shift @pushed;
        last if ($commit2parent{$commit} ne $base);
        my $changeid = $commit2change{$commit};
        my $status = $change2status{$changeid};
        last if ($status ne 'MERGED');
        print "Basing on MERGED $changeid.\n" if ($debug);
        $base = $commit;
    }
    return $base;
}

sub create_commit($$$$$)
{
    my ($base, $tree, $commit_msg, $author, $committer) = @_;

    ($ENV{GIT_AUTHOR_NAME}, $ENV{GIT_AUTHOR_EMAIL}, $ENV{GIT_AUTHOR_DATE}) = @$author;
    ($ENV{GIT_COMMITTER_NAME}, $ENV{GIT_COMMITTER_EMAIL}, $ENV{GIT_COMMITTER_DATE}) = @$committer;
    my @gitcmd = ('git', 'commit-tree', $tree, '-p', $base);
    print "+ @gitcmd\n" if ($debug);
    my ($to_child, $from_child);
    local $SIG{PIPE} = 'IGNORE';
    my $pid = open2($from_child, $to_child, @gitcmd) or die("Failed to run \"git\": $!\n");
    print $to_child $commit_msg;
    close($to_child) or die("Closing STDIN pipe failed: $!\n");
    waitpid($pid, 0) > 0 or die("waitpid() failed: $!\n");
    exit($? >> 8) if ($?);

    chomp($base = <$from_child>);
    print "- $base\n" if ($debug);
    close($from_child);

    return $base;
}

our $indexfile;
my $curr_commit = "";

sub apply_diff($$)
{
    my ($base, $commit) = @_;

    my $show = open_git_pipe('diff', "$commit~..$commit");
    $/ = undef;
    my $diff = <$show>;
    $/ = "\n";
    close_pipe($show, 1);

    if ($curr_commit ne $base) {
        read_git_line('read-tree', $base);
        exit($? >> 8) if ($?);
    }

    my @gitcmd = ('git', 'apply', '--cached', '-C1', '--whitespace=warn');
    print "+ @gitcmd\n" if ($debug);
    open(my $apply, '|-', @gitcmd) or die("Failed to run \"git\": $!\n");
    print $apply $diff;
    if (!close_pipe($apply)) {
        print STDERR "... while applying ".format_commit($commit)."\n";
        exit($? >> 8);
    }

    my $tree = read_git_line('write-tree');
    exit($? >> 8) if ($?);

    return $tree;
}

sub rebase_commit($$$$)
{
    my ($base, $commit, $old_commit, $push_base) = @_;

    die(format_commit($commit).":\n"
            ."Rebasing merges and roots is not supported. Use --simple mode.\n")
        if (length($commit2parent{$commit}) != 40);

    my ($author, $committer) = ($commit2author{$commit}, $commit2committer{$commit});
    printf "- SHA1: %s\n- Author: %s <%s> %s\n- Commit: %s <%s> %s\n",
           $commit, @$author, @$committer
        if ($debug);

    my $tree = apply_diff($base, $commit);

    my $message = $commit2message{$commit};
    $message =~ s/^(Change-Id: \w+)$/$1\nGPush-Base: $push_base/m if (defined($push_base));
    if (defined($old_commit)
        && ($base eq $commit2parent{$old_commit})
        && ($tree eq $commit2tree{$old_commit})
        && ($message eq $commit2message{$old_commit})
        && (@$author eq @{$commit2author{$old_commit}})) {
            # We produced the same contents as last time, so re-use the
            # committer info to obtain the exact same commit. That way
            # any local rebasing will not affect the outcome.
        $committer = $commit2committer{$old_commit};
        printf("- Override commit: %s <%s> %s\n", @$committer) if ($debug);
    }
    $commit = create_commit($base, $tree, $message, $author, $committer);
    $curr_commit = $commit;
    return $commit;
}

# The level can only decrease within a series.
use constant {
    REBASE => 0,     # Created a fresh rebased commit
    RECREATE => 1,   # Re-created the rebased commit from the previous push
    RECYCLE => 2,    # Re-used the rebased commit from the previous push
    VERBATIM => 3    # Took the source commit as-is
};

my @try_label = ('fresh', 're-created', 'recycled', 'verbatim');

sub rebase_commits($$@)
{
    my ($push_base, $base, @commits) = @_;
    my @new_commits = ();

    print "Rebasing ".scalar(@commits)." commit(s) to $base:\n" if ($verbose);

    my $merge_base = read_git_line('merge-base', $push_base, "$origin/$ref_to");
    exit($? >> 8) if ($?);
    if ($merge_base ne $push_base) {
        # Series which are pushed on top of other pending changes have their
        # base stored in the commit messages, as otherwise gpick could not
        # reliably determine it. Other mechanisms of storing that information
        # on Gerrit (e.g., git notes or custom refs) were found not feasible.
        # Obviously, Gerrit should patch out that footer while cherry-picking,
        # to avoid committing meaningless clutter to the target branch.
        # This is expected to affect less than 0.1% of the changes ...
        print "Base is not in the upstream branch; injecting footer.\n" if ($debug);
    } else {
        $push_base = undef;
    }

    local $indexfile = mktemp(($ENV{TMPDIR} or "/tmp") . "/git-gpush.XXXXXX");
    local $ENV{GIT_INDEX_FILE} = $indexfile;

    local %SIG;
    $SIG{PIPE} = "IGNORE";
    $SIG{HUP} = $SIG{INT} = $SIG{QUIT} = $SIG{TERM} = \&exit;
    END { unlink($indexfile) if ($indexfile); }

    my $try = defined($push_base) ? RECREATE : VERBATIM;
    foreach my $commit (@commits) {
        my $changeid = $commit2change{$commit};
        my $subject = $commit2subject{$commit};
        # We attempt to re-create the exact same commits we pushed previously,
        # regardless of any local rebasing.
        my ($new_commit, $old_commit);
        if ($try >= RECREATE) {
            $old_commit = $change2pushed{$changeid};
            if (defined($old_commit)) {
                # The change was previously pushed.
                if ($commit2parent{$old_commit} ne $base) {
                    # The change had previously a different parent commit. It is
                    # irrelevant whether the changes were re-shuffled or the parent
                    # change was amended - a new base will yield a new commit.
                    $old_commit = undef;
                } else {
                    # We are picking the same change to the same base.
                    if ($try >= RECYCLE) {
                        my $orig_commit = $change2orig{$changeid};
                        if (defined($orig_commit)) {
                            if ($orig_commit eq $commit) {
                                # We are picking the same commit to the same base, so
                                # we can just recycle the previously created commit.
                                $new_commit = $old_commit;
                                if ($new_commit ne $commit) {
                                    $try = RECYCLE;
                                    goto PICK;
                                }
                                # Apparently, we recycled a no-op.
                                goto NOOP;
                            }
                        }
                    }
                    print "Trying to re-create previously pushed $old_commit.\n"
                        if ($debug);
                }
            }
        }
        if (!defined($old_commit) && ($commit2parent{$commit} eq $base)
            && !defined($push_base)) {
            # Same base, no reference commit, and no footer injection,
            # so no need to do anything.
            $new_commit = $commit;
            goto NOOP;
        }
        $new_commit = rebase_commit($base, $commit, $old_commit, $push_base);
        if ($new_commit eq $commit) {
            # We didn't rebase and the reference commit had no effect, so we
            # effectively use the source commit verbatim. It's useful not to lower
            # the try level, as subsequent new commits may be actually used verbatim.
            goto NOOP;
        }
        if (defined($old_commit) && ($old_commit eq $new_commit)) {
            # The source commit was amended, but it was just an empty rebase.
            $try = RECREATE;
            goto NOOP;
        }
        $try = REBASE;
      PICK:
        $commit2parent{$new_commit} = $base;
        $commit2change{$new_commit} = $changeid;
        $commit2subject{$new_commit} = $subject;
      NOOP:
        $commit2orig{$new_commit} = $commit;
        print "Picked ".format_change($changeid, $subject)
                ." as ".substr($new_commit, 0, 10)." ($try_label[$try])\n"
            if ($verbose);
        push @new_commits, $new_commit;
        $base = $new_commit;
    }

    unlink($indexfile);

    return @new_commits;
}

sub config_gerrit()
{
    $gerrit_ssh = git_config('remote.'.$remote.'.url');
    die("Remote '$remote' does not exist.\n") if (!$gerrit_ssh);
    $gerrit_ssh =~ s/\.git$//;
    die("Remote '$remote' does not use the SSH protocol.\n")
        if ($gerrit_ssh !~ m,^ssh://([^/]+)/(.*)$,);
    $gerrit_ssh = $1;
    $gerrit_project = $2;
}

sub query_gerrit(@)
{
    my @changes = @_;

    my $status = open_cmd_pipe('ssh', $gerrit_ssh, 'gerrit', 'query', '--format', 'JSON',
                               '--patch-sets', "project:$gerrit_project", "branch:$ref_to",
                               '\\('.join(' OR ', @changes).'\\)');
    while (<$status>) {
        print "- $_" if ($debug);
        my $review = decode_json($_);
        defined($review) or die("Cannot decode JSON string '".chomp($_)."'\n");
        my $changeid = $$review{'id'};
        next if (!defined($changeid));
        my $status = $$review{'status'};
        defined($status) or die("Huh?! $changeid has no status?\n");
        $change2status{$changeid} = $status;
        my $pss = $$review{'patchSets'};
        defined($pss) or die("Huh?! $changeid has no patchsets?\n");
        my @revisions = ();
        foreach my $cps (@{$pss}) {
            my ($number, $revision) = ($$cps{'number'}, $$cps{'revision'});
            defined($number) or die("Huh?! Patchset in ${changeid} has no number?\n");
            defined($revision) or die("Huh?! Patchset $number in ${changeid} has no commit?\n");
            $revisions[$number] = $revision;
            $knownsha1s{$revision} = 1;
        }
        $change2current{$changeid} = $revisions[-1];
    }
    close_pipe($status, 1);
}

sub do_push_commits($)
{
    my ($from) = @_;

    my $is_draft = $commit2draftness{$from};

    my @gitcmd = ("git", "push");
    push @gitcmd, '-v' if ($verbose);
    push @gitcmd, '-q' if ($quiet);
    push @gitcmd, '-n' if ($dry_run);
    push @gitcmd, @arguments;
    push @gitcmd, $remote, $from.":refs/".($is_draft ? "drafts" : "for")."/".$ref_to;

    print '+ '.format_cmd(@gitcmd)."\n" if ($debug);
    my $ex = system(@gitcmd);
    die("Failed to run \"git\": $!\n") if ($ex < 0);
    exit($ex >> 8) if ($ex);
}

sub push_patches(@)
{
    my @commits = @_;

    # First filter out our unmodified changes that already went in.
    my $idx = 0;
    while (1) {
        if ($idx == @commits) {
            print "No unmerged commits. Use git gpull to rebase.\n";
            exit 0;
        }
        my $commit = $commits[$idx];
        # Note that this misses changes that have no post-cherry-pick patchset
        # (old Gerrit or some botch-up). No big deal ...
        last if (!defined($knownsha1s{$commit}));
        last if ($change2status{$commit2change{$commit}} ne 'MERGED');
        if ($simple) {
            print "Dropping MERGED $commit.\n" if ($debug);
            shift @commits;
        } else {
            print "Marking $commit as MERGED.\n" if ($debug);
            $commit2freshness{$commit} = MERGED;
            $idx++;
        }
    }

    # Next, mark unmodified commits.
    while (1) {
        if ($idx == @commits) {
            print "No modified commits - nothing to push.\n";
            exit 0;
        }
        my $commit = $commits[$idx];
        last if (!defined($knownsha1s{$commit}));
        print "Marking $commit as known.\n" if ($debug);
        my $changeid = $commit2change{$commit};
        $commit2freshness{$commit} = ($commit eq $change2current{$changeid}) ? UNMODIFIED : OUTDATED;
        # Can't change the draft status with a push without a modificiation.
        $commit2draftness{$commit} = ($change2status{$changeid} eq 'DRAFT');
        $idx++;
    }

    # All remaining commits are new.
    my $need_force = 0;
    my ($lastpublic, $firstdraft);
    for (; $idx < @commits; $idx++) {
        my $commit = $commits[$idx];
        my $changeid = $commit2change{$commit};
        my $freshness = MODIFIED;
        if (defined($change2current{$changeid})) {
            my $status = $change2status{$changeid};
            if ($status ne 'NEW' && $status ne 'DRAFT') {
                # We are attempting a push which Gerrit will reject anyway.
                my $chstr = format_change($changeid, $commit2subject{$commit});
                die("Change ".$chstr." is already MERGED; use git gpull to rebase.\n")
                    if ($status eq 'MERGED');
                die("Change ".$chstr." is currently $status; cannot proceed.\n")
                    if ($status eq 'SUBMITTED' || $status eq 'STAGING'
                        || $status eq 'STAGED' || $status eq 'INTEGRATING');
                die("Change ".$chstr." is $status; please restore it first.\n")
                    if ($status eq 'DEFERRED' || $status eq 'ABANDONED');
                die("Change ".$chstr." is $status. I'm stumped. :}\n");
            }
            # Finally, check whether the current patchset on Gerrit meets our
            # expectations, so we don't accidentally play ping-pong.
            my $pushed = $change2pushed{$changeid};
            if (!defined($pushed) || $change2current{$changeid} ne $pushed) {
                # Having no pushed commit may indicate both that gpick --check was not used or
                # that it found divergence. It doesn't appear useful to differentiate the two.
                $freshness = FORCE;
                $need_force = 1;
            }
        }
        $commit2freshness{$commit} = $freshness;
        if (!$firstdraft) {
            if ($draft ? ($draft > 0) : ($commit2subject{$commit} =~ /\bWIP\b|\*{3}|^(.)\1*$/i)) {
                $firstdraft = $commit;
            } else {
                $lastpublic = $commit;
            }
        }
        $commit2draftness{$commit} = $firstdraft;
    }

    if (!$quiet || ($need_force && !$force)) {
        print "Pushing ".scalar(@commits)." commit(s) for $ref_to on $remote:\n";
        foreach my $commit (@commits) {
            my @attribs = ();
            push @attribs, 'DRAFT' if ($commit2draftness{$commit});
            my $freshness = $commit2freshness{$commit};
            push @attribs, $freshness if ($freshness ne MODIFIED);
            my $suffix = @attribs ? ' ['.join('; ', @attribs).']' : '';
            print "  ".format_commit($commit).$suffix."\n";
        }
        die("Local state is out of sync with Gerrit.\n".
                "Please use git gpick, or specify --force to push nonetheless.\n")
            if ($need_force && !$force);
    }

    do_push_commits($lastpublic) if ($firstdraft && $lastpublic);
    do_push_commits($commits[-1]);

    if (!$dry_run) {
        my @gitcmd = ("git", "update-ref", "--stdin");
        print "+ @gitcmd\n" if ($debug);
        open(my $pipe, '|-', @gitcmd) or die("Failed to run \"git\": $!\n");
        local $SIG{PIPE} = "IGNORE";
        foreach my $commit (@commits) {
            my $ref = "refs/gpush/$ref_to/$commit2change{$commit}";
            print "> update ${ref}_pushed $commit\n" if ($debug);
            print $pipe "update ${ref}_pushed $commit\n";
            print "> update ${ref}_base $commit2parent{$commits[0]}\n" if ($debug);
            print $pipe "update ${ref}_base $commit2parent{$commits[0]}\n";
            if (!$simple) {
                print "> update ${ref}_orig $commit2orig{$commit}\n" if ($debug);
                print $pipe "update ${ref}_orig $commit2orig{$commit}\n";
            }
        }
        close_pipe($pipe, 1);
    }
}

load_config();
parse_arguments(@ARGV);
add_reviewers();
goto_gitdir();
config_gerrit();
my @commits = get_patches();
if (!$simple) {
    if (!$commit_count && !defined($ref_from_base)) {
        walk_all_pushes();
        @commits = determine_series(@commits);
    } else {
        walk_selected_pushes(@commits);
    }
    my $push_base = determine_base(@commits);
    my @old_commits = get_omitted($commits[0]);
    query_gerrit(map { $commit2change{$_} } (@commits, @old_commits));
    my $base = advance_base($push_base, @old_commits);
    @commits = rebase_commits($push_base, $base, @commits);
} else {
    query_gerrit(map { $commit2change{$_} } @commits);
}
push_patches(@commits);
-------------- next part --------------
#!/usr/bin/env perl
# Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
# Contact: http://www.qt-project.org/legal
#
# You may use this file under the terms of the 3-clause BSD license.
# See the file LICENSE from this package for details.
#

use strict;
use warnings;
no warnings qw(io);

use Carp;
$SIG{__WARN__} = \&Carp::cluck;

use List::Util qw(first);
use Cwd qw(abs_path);
use File::Spec;
use File::Temp qw(mktemp);
use IPC::Open2 qw(open2);
use IPC::Open3 qw(open3);
use JSON;

sub usage
{
    print << "EOM";
Usage:
    git gpick [options] {[\@base] [+]change[\@] | /change}...

    Updates local commits with the newest patchsets from Gerrit.

Description:
    This program fetches the current state of the specified changes from
    Gerrit, and replaces the local commits with it.

    Commits are updated in place, unless a base is specified, in which case
    they are moved right after the base revision. Changes with no corresponding
    local commit need to be prefixed by a plus sign and are picked on top of
    HEAD by default. If a change is suffixed by an at-sign, it is made the
    base for the subsequent change.

    Local commits may be specified as either SHA1s or Gerrit Change-Ids,
    possibly abbreviated. Git rev-spec suffixes like '~2' are allowed.
    It is also possible to specify ranges, either as <base>:<tip> or as
    <tip>:<count>. Note that within a range the local order of the commits
    will be preserved even if the series was re-ordered remotely.

    Local commits may be dropped by prefixing them with a slash.

Options:
    -c, --check
        Rather than replacing any commits, synchronize the state information
        with Gerrit. This is necessary when commits were previously pushed
        or picked without (or with an older version of) gpush or gpick,
        respectively.
        If no changes are specified, everything is synchronized. This can
        take a while, so using it with --verbose is recommended.

    -f, --force
        Replace the local commits even if they differ from the ones that were
        previously pushed from this clone.

    -r, --remote
        Specify the remote used to determine the Gerrit server to fetch
        patchsets from. The default is 'gerrit'.

    -b, --branch
        Specify the branch for which the changes to download were pushed
        to Gerrit. The default is the upstream of the current local branch.

    -n, --dry-run
        Do everything except actually replacing any commits.

    -v, --verbose
        Show which actions are actually performed, and other information.

    -q, --quiet
        Suppress the usual output from the called git commands.

    --debug
        Print debug information.

Copyright:
    Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
    Contact: http://www.qt-project.org/legal

License:
    You may use this file under the terms of the 3-clause BSD license.
EOM
}

my $script = abs_path($0);

my $debug = 0;
my $verbose = 0;
my $quiet = 0;
my $dry_run = 0;

my $rebase_action;
my $refupdates;
our $todo;

my $gerrit;
my $branch;
my $check = 0;
my $force = 0;
my @commit_specs = ();

my $gerrit_ssh;
my $origin;
my $gerrit_project;

my $gitdir;
my %gitconfig = ();

my %commit2parent = ();
my %commit2change = ();
my %commit2subject = ();
my %commit2message = ();
my %commit2tree = ();
my %commit2author = ();
my %commit2base = ();

my %change2pushed = ();
my %change2orig = ();
my %change2action = ();
my %change2local = ();
my %change2current = ();
my %change2remote = ();
my %change2subject = ();
my %change2status = ();
my %change2ref = ();

sub format_cmd(@)
{
    return join(' ', map { /\s/ ? '"' . $_ . '"' : $_ } @_);
}

sub close_pipe($;$)
{
    close(shift) and return 1;
    die("Closing pipe failed: $!\n") if ($!);
    if ($? & 128) {
        die("Process crashed with signal $?.\n") if ($? != 141); # allow SIGPIPE
        $? = 0;
    } elsif ($? && shift) {
        exit($? >> 8);
    }
    return 0;
}

sub open_cmd_pipe(@)
{
    print "+ ".format_cmd(@_)."\n" if ($debug);
    open(my $pipe, '-|', @_) or die("Failed to run \"$_\": $!\n");
    return $pipe;
}

sub read_cmd_line(@)
{
    my $pipe = open_cmd_pipe(@_);
    my $line = <$pipe>;
    if (defined($line)) {
        chomp $line ;
        print "- $line\n" if ($debug);
    }
    close_pipe($pipe);
    return $line;
}

sub open_git_pipe(@)
{
    return open_cmd_pipe('git', @_);
}

sub read_git_line(@)
{
    return read_cmd_line('git', @_);
}

sub call_git(@)
{
    my (@gitcmd) = ('git', @_);

    print '+ '.format_cmd(@gitcmd)."\n" if ($debug);
    my $ex = system(@gitcmd);
    die("Failed to run \"git\": $!\n") if ($ex < 0);
}

sub parse_arguments(@)
{
    while (scalar @_) {
        my $arg = shift @_;

        if ($arg eq "-v" || $arg eq "--verbose") {
            $verbose = 1;
        } elsif ($arg eq "-q" || $arg eq "--quiet") {
            $quiet = 1;
        } elsif ($arg eq "--debug") {
            $debug = 1;
            $verbose = 1;
        } elsif ($arg eq "-n" || $arg eq "--dry-run") {
            $dry_run = 1;
        } elsif ($arg eq "-r" || $arg eq "--remote") {
            $gerrit = shift @_;
        } elsif ($arg eq "-b" || $arg eq "--branch") {
            $branch = shift @_;
        } elsif ($arg eq "-c" || $arg eq "--check") {
            $check = 1;
        } elsif ($arg eq "-f" || $arg eq "--force") {
            $force = 1;
        } elsif ($arg eq "--continue" || $arg eq "--skip" || $arg eq "--abort") {
            $rebase_action = $arg;
        } elsif ($arg eq "-?" || $arg eq "--?" || $arg eq "-h" || $arg eq "--help") {
            usage();
            exit 0;
        } elsif ($arg !~ /^-/) {
            push @commit_specs, $arg;
        } else {
            die("Invalid option '$arg'.\n");
        }
    }

    die("Rebase actions are mutually exclusive with regular gpick options.\n")
        if (defined($rebase_action)
            && ($dry_run || $gerrit || $branch || $force || $check || @commit_specs));

    die("--quiet and --verbose/--debug are mutually exclusive.\n")
        if ($quiet && $verbose);

    die("--force and --check are mutually exclusive.\n")
        if ($force && $check);

    @commit_specs = ('@{upstream}..') if (!@commit_specs && $check);
}

sub git_configs($)
{
    my ($key) = @_;
    my $ref = $gitconfig{$key};
    return defined($ref) ? @$ref : ();
}

sub git_config($;$)
{
    my ($key, $dflt) = @_;
    my @cfg = git_configs($key);
    return scalar(@cfg) ? $cfg[-1] : $dflt;
}

sub load_config()
{
    # Read all git configuration at once, as that's faster than repeated
    # git invocations, especially under Windows.
    foreach (`git config --list`) {
        die("Malformed git config output '$_'.\n") if (!/^([^=]+)=(.*)$/);
        push @{$gitconfig{$1}}, $2;
    }

    $gerrit = git_config('gpush.remote', 'gerrit');
}

sub goto_gitdir()
{
    my $cdup = read_git_line('rev-parse', '--show-cdup');
    exit($? >> 8) if ($?);
    die("fatal: This operation must be run in a work tree\n") if (!defined($cdup));
    chdir($cdup) unless ($cdup eq "");
    $gitdir = read_git_line('rev-parse', '--git-dir');
    exit($? >> 8) if ($?);
}

sub determine_target()
{
    # Unlike in gpush, it makes no sense to work with mid-rebase states
    # (as we are going to rewrite the branch ourselves).
    my $ref = read_git_line('symbolic-ref', '-q', '--short', 'HEAD');
    die("Cannot proceed, not on any branch currently.\n") if ($? != 0);
    if (!$branch) {
        $branch = git_config("branch.$ref.merge");
        die("$ref has no tracking branch.\n") if (!defined($branch));
        $branch =~ s,^refs/heads/,,;
    }
    $origin = git_config("branch.$ref.remote");
    if (!defined($origin)) {
        $origin = git_config('gpush.origin', 'origin');
        print STDERR "Notice: $ref has no upstream remote; defaulting to $origin.\n" if (!$quiet);
    }
}

sub format_change($$)
{
    my ($changeid, $subject) = @_;
    $subject =~ s/^(.{50}).{5,}$/$1\[...]/;
    return substr($changeid, 0, 10)." ($subject)";
}

sub query_refs()
{
    my $refs = open_git_pipe('show-ref');  # No way to filter the namespace :(
    while (<$refs>) {
        print "- $_" if ($debug);
        chop;
        if (/^(.{40}) refs\/gpush\/\Q$branch\E\/([^_]+)_(.*)$/o) {
            if ($3 eq 'pushed') {
                $change2pushed{$2} = $1;
            } elsif ($3 eq 'remote') {
                $change2current{$2} = $1;
            } elsif ($3 eq 'orig') {
                $change2orig{$2} = $1;
            }
        }
    }
    close_pipe($refs, 1);
}

sub update_refs(@)
{
    my (@updates) = @_;

    if (!@updates) {
        print "No refs to update.\n" if ($debug);
        return;
    }
    my @gitcmd = ("git", "update-ref", "--stdin");
    print "+ @gitcmd\n" if ($debug);
    open(my $pipe, '|-', @gitcmd) or die("Failed to run \"git\": $!\n");
    local $SIG{PIPE} = "IGNORE";
    foreach (@updates) {
        print "> $_" if ($debug);
        print $pipe "$_";
    }
    close_pipe($pipe, 1);
}

sub read_fields($@)
{
    my $fh = shift;
    return 0 if (eof($fh));
    local $/ = "\0";
    for (@_) { chop($_ = <$fh>); }
    return 1;
}

use constant GIT_LOG_ARGS =>
        ('--first-parent',
         '-z', '--pretty=%H%x00%P%x00%T%x00%B%x00%an%x00%ae%x00%ad');

sub process_log($)
{
    my ($log) = @_;
    my @commits = ();

    my @author = (undef, undef, undef);
    while (read_fields($log, my ($commit, $parents, $tree, $message), @author)) {
        $message =~ /^(.*)$/m;
        my $subject = $1;

        die(format_change($commit, $subject)." has no Change-Id.\n")
            if ($message !~ /^Change-Id: (.+)$/m);
        my $changeid = $1;

        print "-- $commit: ".format_change($changeid, $subject)."\n" if ($debug);

        $commit2parent{$commit} = $parents;
        $commit2change{$commit} = $changeid;
        $commit2subject{$commit} = $subject;
        $commit2message{$commit} = $message;
        $commit2tree{$commit} = $tree;
        $commit2author{$commit} = [ @author ];
        unshift @commits, $commit;
    }
    return @commits;
}

sub visit_revs($@)
{
    my ($walk, @tips) = @_;

    my @gitcmd = ('git', 'log', GIT_LOG_ARGS, '--stdin',
                  $walk ? ('--first-parent', "^$origin/$branch") : ('--no-walk'));
    print "+ @gitcmd\n" if ($debug);
    my ($to_child, $log);
    my $pid = open2($log, $to_child, @gitcmd) or die("Failed to run \"git\": $!\n");
    local $SIG{PIPE} = "IGNORE";
    foreach (@tips) {
        print "> $_\n" if ($debug);
        print $to_child "$_\n";
    }
    close($to_child) or die("Closing STDIN pipe failed: $!\n");
    process_log($log);
    close($log);
    waitpid($pid, 0) > 0 or die("waitpid() failed: $!\n");
    exit($? >> 8) if ($?);
}

sub get_patches()
{
    # Get all local changes.
    my $log = open_git_pipe('log', GIT_LOG_ARGS, "$origin/$branch..HEAD");
    my @commits = process_log($log);
    close_pipe($log, 1);

    my @changes = ();
    foreach my $commit (@commits) {
        die("Merges and roots are not supported.\n")
            if (length($commit2parent{$commit}) ne 40);
        my $changeid = $commit2change{$commit};
        $change2local{$changeid} = $commit;
        push @changes, $changeid;
    }
    return ($commit2parent{$commits[0]}, @changes);
}

sub full_changeid($$)
{
    my ($id, $candidates) = @_;

    return $id if (defined($$candidates{$id}));

    my @changes = ();
    my $rx = qr/^\Q$id\E/;
    foreach my $change (keys %{$candidates}) {
        push @changes, $change if ($change =~ $rx);
    }
    return undef if (!@changes);
    return $changes[0] if (@changes == 1);
    die("$id is ambiguous.\n");
}

sub full_local_changeid($)
{
    return full_changeid(shift, \%change2local);
}

sub full_remote_changeid($)
{
    return full_changeid(shift, \%change2remote);
}

sub parse_rev($;$)
{
    my ($rev, $root) = @_;

    if (defined($root)) {
        return $root if ($rev eq '@{u}' || $rev eq '@{upstream}');
    } else {
        die("No local changes.\n") if (!%commit2change);
    }
    $rev = 'HEAD' if (!length($rev));
    my $out = read_git_line('rev-parse', '--verify', '-q', $rev);
    if (!$out) {
        die("$rev is not a valid revspec.\n") if ($rev !~ /^(\w+)(.*)$/);
        my ($id, $rest) = ($1, $2);
        my $changeid = full_local_changeid($id);
        die("$id does not refer to a revision in the local branch.\n")
            if (!defined($changeid));
        my $commit = $change2local{$changeid};
        return $commit if (!$rest);
        $out = read_git_line('rev-parse', '--verify', '-q', $commit.$rest);
        die("$rev is not a valid revspec.\n") if (!$out);
    }
    return $out if (defined($root) && ($out eq $root));
    return $out if (defined($commit2change{$out}));
    die("$rev is outside the local branch.\n");
}

sub config_gerrit()
{
    $gerrit_ssh = read_git_line('config', '--get', 'remote.'.$gerrit.'.url');
    die("Remote '$gerrit' does not exist.\n") if (!$gerrit_ssh);
    $gerrit_ssh =~ s/\.git$//;
    die("Remote '$gerrit' does not use the SSH protocol.\n")
        if ($gerrit_ssh !~ m,^ssh://([^/]+)/(.*)$,);
    $gerrit_ssh = $1;
    $gerrit_project = $2;
}

sub query_gerrit(@)
{
    my (@changes) = @_;

    my $status = open_cmd_pipe('ssh', $gerrit_ssh, 'gerrit', 'query', '--format', 'JSON',
                               '--current-patch-set', "project:$gerrit_project", "branch:$branch",
                               '\\('.join(' OR ', @changes).'\\)');
    while (<$status>) {
        print "- $_" if ($debug);
        my $review = decode_json($_);
        defined($review) or die("Cannot decode JSON string '".chomp($_)."'\n");
        my $changeid = $$review{'id'};
        next if (!defined($changeid));
        my ($subject, $status, $cps) =
            ($$review{'subject'}, $$review{'status'}, $$review{'currentPatchSet'});
        defined($subject) or die("Huh?! $changeid has no subject?\n");
        defined($status) or die("Huh?! $changeid has no status?\n");
        defined($cps) or die("Huh?! $changeid has no current patchset?\n");
        my ($commit, $ref) = ($$cps{'revision'}, $$cps{'ref'});
        defined($commit) or die("Huh?! ${changeid}'s current patchset has no commit?\n");
        defined($ref) or die("Huh?! ${changeid}'s current patchset has no ref?\n");
        $change2subject{$changeid} = $subject;
        $change2status{$changeid} = $status;
        $change2remote{$changeid} = $commit;
        $change2ref{$changeid} = $ref;
    }
    close_pipe($status, 1);
}

sub create_commit($$$$)
{
    my ($base, $tree, $commit_msg, $author) = @_;

    ($ENV{GIT_AUTHOR_NAME}, $ENV{GIT_AUTHOR_EMAIL}, $ENV{GIT_AUTHOR_DATE}) = @$author;
    my @gitcmd = ('git', 'commit-tree', $tree, '-p', $base);
    print "+ @gitcmd\n" if ($debug);
    my ($to_child, $from_child);
    local $SIG{PIPE} = 'IGNORE';
    my $pid = open2($from_child, $to_child, @gitcmd) or die("Failed to run \"git\": $!\n");
    print $to_child $commit_msg;
    close($to_child) or die("Closing STDIN pipe failed: $!\n");
    waitpid($pid, 0) > 0 or die("waitpid() failed: $!\n");
    exit($? >> 8) if ($?);

    chomp($base = <$from_child>);
    print "- $base\n" if ($debug);
    close($from_child);

    return $base;
}

sub set_base($)
{
    my ($commit) = @_;

    my $parent = $commit2parent{$commit};
    return $commit if (!defined($parent));
    my $base = $commit2base{$commit};
    return $base if (defined($base));
    $base = &set_base($parent);
    $commit2base{$commit} = $base;
    return $base;
}

sub fetch_patchsets(@)
{
    my (@changes) = @_;

    my @refs = ();
    my %tips = ();
    foreach my $id (@changes) {
        my $changeid = full_remote_changeid($id);
        if (!defined($changeid)) {
            if ($check) {
                print "$id was never pushed for $branch.\n" if ($debug);
                next;
            }
            die("Change $id was never pushed for $branch.\n");
        }
        print "Warning: Change ".format_change($changeid, $change2subject{$changeid})
                ." is MERGED; use git gpull to rebase.\n"
            if (!$quiet && $change2status{$changeid} eq 'MERGED');
        my ($curr, $rmt) = ($change2current{$changeid}, $change2remote{$changeid});
        if (!defined($curr) || $curr ne $rmt) {
            push @refs, "+$change2ref{$changeid}:refs/gpush/$branch/${changeid}_remote";
        } else {
            print "Have latest PatchSet for $changeid.\n" if ($debug);
        }
        $tips{$rmt} = 1;
    }
    if (@refs) {
        print "Fetching current PatchSets ...\n" if (!$quiet);
        my @gitcmd = ("fetch");
        # The git-fetch output is quite noisy and unhelpful here, unless debugging.
        push @gitcmd, '-q' if (!$debug);
        push @gitcmd, $gerrit, @refs;
        call_git(@gitcmd);
        exit($? >> 8) if ($?);
    } else {
        print "No PatchSets need fetching.\n" if ($debug);
    }

    visit_revs(1, keys %tips);

    # Find the bases of the fetched PatchSets.
    %change2current = ();  # Re-cycle
    foreach my $commit (keys %tips) {
        my ($changeid, $message) = ($commit2change{$commit}, $commit2message{$commit});
        if (!defined($changeid)) {
            # Commit was merged and is thus excluded by ^$origin/$branch.
            print "$commit was not listed (already merged).\n" if ($debug);
            next;
        }
        # Series which are pushed on top of other pending changes have their
        # base stored in the commit messages, as otherwise it's indeterminable.
        # We patch that footer out before cherry-picking the changes into the
        # local branch, because locally we store the base in a ref anyway.
        if ($message =~ s/^GPush-Base: (\w{40})\n$//m) {
            my $base = $1;
            $commit2base{$commit} = $base;
            delete $tips{$commit};
            print "Have push-base $base for $commit.\n" if ($debug);
            my ($parents, $tree, $author)
                = ($commit2parent{$commit}, $commit2tree{$commit}, $commit2author{$commit});
            my $new_commit = create_commit($parents, $tree, $message, $author);
            $commit2parent{$new_commit} = $parents;
            $commit2change{$new_commit} = $changeid;
            $commit2subject{$new_commit} = $commit2subject{$commit};
            $commit2message{$new_commit} = $message;
            $commit2tree{$new_commit} = $tree;
            $commit2author{$new_commit} = $author;
            $commit = $new_commit;
        }
        $change2current{$changeid} = $commit;
    }

    # For the remaining PatchSets, the merge-base with upstream is the base.
    set_base($_) foreach (keys %tips);
}

use constant {
    IGNORE => undef,
    INSERT => 1,
    REPLACE => 2,
    DELETE => 3
};

sub apply_specs($@)
{
    my ($root, @changes) = @_;

    my $idx;
    my %touched = ();
    foreach my $cs (@commit_specs) {
        if ($cs =~ s/^\@(?!\{)//) {
            die("Cannot specify adjacent bases (2nd is $cs).\n") if (defined($idx));
            my $base = $commit2change{parse_rev($cs, $root)};
            $idx = first { $changes[$_] eq $base } 0..$#changes;
            # Note that it's actually possible to base on changes we delete.
        } else {
            my $isbase = ($cs =~ s/\@$//);
            if ($cs =~ s/^\+//) {
                die("Cannot specify an addition when checking.\n") if ($check);
                die("Cannot add $cs, because it already exists locally.\n")
                    if (defined(full_local_changeid($cs)));
                $idx = @changes if (!defined($idx));
                splice @changes, $idx, 0, $cs;
                $change2action{$cs} = INSERT;
                $idx = $isbase ? ($idx + 1) : undef;
                next;
            }
            my $action = ($cs =~ s/^\///) ? DELETE : REPLACE;
            my @range = ();
            if ($cs =~ /^(.*)\.\.(.*)$/) {
                my ($base, $tip) = (parse_rev($1, $root), parse_rev($2));
                while ($tip ne $base) {
                    unshift @range, $tip;
                    $tip = $commit2parent{$tip};
                }
            } elsif ($cs =~ /^(.*):(.*)$/) {
                my ($tip, $count) = (parse_rev($1), $2);
                while (--$count >= 0) {
                    unshift @range, $tip;
                    $tip = $commit2parent{$tip};
                    die("Range $cs extends beyond local branch.\n") if (!defined($tip));
                }
            } else {
                my $tip = parse_rev($cs);
                unshift @range, $tip;
            }
            @range = map { $commit2change{$_} } @range;

            my $prev = first { defined($touched{$_}) } @range;
            die("Spec $cs intersects with another spec (change $prev).\n") if (defined($prev));
            $touched{$_} = 1 foreach (@range);

            my $rmidx = first { $changes[$_] eq $range[0] } 0..$#changes;
            if ($action == DELETE) {
                die("Cannot specify a removal when checking.\n") if ($check);
                die("Cannot specify a removal (-$cs) with a base.\n") if (defined($idx));
                $idx = $rmidx;
            } else {
                if (defined($idx) && $idx ne $rmidx) {
                    print "Moving $changes[$idx] from $rmidx to $idx.\n" if ($debug);
                    splice @changes, $rmidx, @range;
                    if ($idx >= $rmidx) {
                        die("Base $changes[$idx] lies within replaced range ($cs).\n")
                            if ($idx < $rmidx + @range);
                        $idx -= @range;
                    }
                    splice @changes, $idx, 0, @range;
                } else {
                    $idx = $rmidx;
                }
            }
            $change2action{$_} = $action foreach (@range);
            $idx = $isbase ? ($idx + @range) : undef;
        }
        die("Cannot specify a base when checking.\n") if (defined($idx) && $check);
    }
    die("Cannot specify a base without subsequent commit.\n") if (defined($idx));
    die("No commits specified.\n") if (!%change2action && !%touched);
    return @changes;
}

our $indexfile;
my $curr_commit = "";

sub apply_diff($$)
{
    my ($base, $commit) = @_;
    my $tree = "";

    my $show = open_git_pipe('diff', "$commit~..$commit");
    $/ = undef;
    my $diff = <$show>;
    $/ = "\n";
    close_pipe($show, 1);

    if ($curr_commit ne $base) {
        read_git_line('read-tree', $base);
        exit($? >> 8) if ($?);
    }

    my @gitcmd = ('git', 'apply', '--cached', '-C1', '--whitespace=warn');
    print "+ @gitcmd\n" if ($debug);
    open(NUL, '>'.File::Spec->devnull()) or die("Failed to open bitbucket: $!\n");
    open3(my $apply, '>&NUL', '>&NUL', @gitcmd) or die("Failed to run \"git\": $!\n");
    close(NUL);
    print $apply $diff;
    close($apply) or die("Failed to close pipe: $!\n");
    wait();
    if (!$?) {
        $tree = read_git_line('write-tree');
        exit($? >> 8) if ($?);
    }
    return $tree;
}

sub verify_commit($$$;$)
{
    my ($base, $commit, $ref_commit, $cutbase) = @_;

    return () if ($commit eq $ref_commit);

    my $tree = apply_diff($base, $commit);
    my ($author, $message) = ($commit2author{$commit}, $commit2message{$commit});
    my @fails = ();
    push @fails, 'author' if (@$author ne @{$commit2author{$ref_commit}});
    my $ref_message = $commit2message{$ref_commit};
    $ref_message =~ s/^GPush-Base: (\w{40})\n$//m if (defined($cutbase));
    push @fails, 'message' if ($message ne $ref_message);
    if ($tree ne $commit2tree{$ref_commit}) {
        push @fails, 'diff';
        $curr_commit = "";
    } else {
        $curr_commit = $ref_commit;
    }
    return @fails;
}

sub adjust_changes(@)
{
    my (@changes) = @_;

    local $indexfile = mktemp(($ENV{TMPDIR} or "/tmp") . "/git-gpush.XXXXXX");
    local $ENV{GIT_INDEX_FILE} = $indexfile;

    local %SIG;
    $SIG{PIPE} = "IGNORE";
    $SIG{HUP} = $SIG{INT} = $SIG{QUIT} = $SIG{TERM} = \&exit;
    END { unlink($indexfile) if ($indexfile); }

    my @commits = ();
    my @updates = ();
    my $any_changed = 0;
    for my $changeid (@changes) {
        my $commit = $change2local{$changeid};
        if (!defined($commit)) {
            $changeid = full_remote_changeid($changeid);
            $commit = $change2current{$changeid};
        }
        my $chstr = format_change($changeid, $commit2subject{$commit}) if (!$quiet);
        my $action = $change2action{$changeid};
        if (!defined($action)) {
            print "Keeping $chstr\n" if ($debug);
            goto PICK;
        }
        if ($action == DELETE) {
            print "Dropping $chstr\n" if ($verbose);
            $any_changed = 1;
            next;
        }
        my $rmt_commit = $change2remote{$changeid};
        if (!defined($rmt_commit)) {
            # Getting here implies $check.
            print "$chstr was never pushed.\n" if ($verbose);
            goto PICK;
        }
        if ($action == INSERT) {
            print "Adding $chstr\n" if ($verbose);
            $any_changed = 1;
            goto UPDATE;
        }
        my ($lcl_commit, $upd_commit) = ($commit, undef);
        my ($curr_commit, $orig_commit, $old_commit)
            = ($change2current{$changeid}, $change2orig{$changeid}, $change2pushed{$changeid});
        if (!defined($curr_commit)) {
            # Commit was merged and is thus excluded by ^$origin/$branch.
            if ($check) {
                print "$chstr was already merged.\n" if ($verbose);
            } else {
                print "Keeping $chstr (merged)\n" if ($debug);
            }
            goto PICK;
        }
        if (defined($orig_commit) && ($orig_commit eq $commit) && defined($old_commit)) {
            # If we are testing a commit which did not change since the last time
            # it was pushed, we can just use the rebased pushed commit instead.
            # This may make the verify_commit() calls very cheap.
            $lcl_commit = $old_commit;
            # Make sure that GPush-Base presence is consistent.
            $curr_commit = $rmt_commit;
        }
        # First make a direct comparison. This should be the common case (the user
        # is expected to push out updates timely), and also covers bootstrapping.
        my @fails = verify_commit($commit2parent{$curr_commit}, $lcl_commit, $curr_commit);
        if (!@fails) {
            if ($check) {
                print "$chstr is identical.\n" if ($verbose);
            } else {
                print "Keeping $chstr (is identical)\n" if ($debug);
            }
            # Use local commit to avoid pointless rewriting, but record new SHA1.
            $upd_commit = $commit;
            goto UPDATE;
        }
        if (defined($old_commit)) {
            # Then do a primitive 3-way diff with the previously pushed commit.
            my $base = $commit2parent{$old_commit};
            my @rmt_fails = verify_commit($base, $rmt_commit, $old_commit);
            if (!@rmt_fails) {
                if ($check) {
                    print "$chstr is up-to-date.\n" if ($verbose);
                } else {
                    print "Keeping $chstr (is up-to-date)\n" if ($debug);
                }
                # Use local commit to avoid pointless rewriting, but record new SHA1.
                $upd_commit = $commit;
                goto UPDATE;
            }
            my @lcl_fails = verify_commit($base, $lcl_commit, $old_commit, 1);
            if (@lcl_fails) {
                # This could be a lot smarter: a changed diff does not really conflict
                # with a changed message. Also, we could do actual 3-way merges.
                if (!$force) {
                    if (!$quiet) {
                        my $conflict = "(local: ".join(", ", @lcl_fails)
                                      .", remote: ".join(", ", @rmt_fails).")";
                        if ($check) {
                            print "$chstr conflicts $conflict.\n";
                        } else {
                            print "NOT replacing conflicting $chstr $conflict\n";
                        }
                    }
                    goto PICK;
                }
                print "Forcibly replacing $chstr\n" if (!$quiet);
            } else {
                if ($check) {
                    print "$chstr needs update.\n" if (!$quiet);
                    goto PICK;
                }
                print "Replacing $chstr\n" if ($verbose);
            }
        } else {
            if (!$force) {
                if (!$quiet) {
                    my $conflict = "(".join(", ", @fails).")" ;
                    if ($check) {
                        print "$chstr diverges (".join(", ", @fails).").\n";
                    } else {
                        print "NOT replacing divergent $chstr $conflict\n";
                    }
                }
                goto PICK;
            }
            print "Forcibly replacing $chstr\n" if (!$quiet);
        }
        $commit = $curr_commit;
        $any_changed = 1;
      UPDATE:
        my $ref = "refs/gpush/$branch/$changeid";
        my $pushed = $change2pushed{$changeid};
        if (!defined($pushed) || ($pushed ne $rmt_commit)) {
            push @updates, "update ${ref}_pushed $rmt_commit\n";
            push @updates, "update ${ref}_base $commit2base{$rmt_commit}\n";
        }
        # No point in recording this if any commits up to here changed,
        # as the cherry-picking will change the sha1s anyway.
        push @updates, "update ${ref}_orig $upd_commit\n"
            if (!$any_changed && defined($upd_commit)
                && (!defined($orig_commit) || ($upd_commit ne $orig_commit)));
      PICK:
        push @commits, $commit;
    }

    unlink($indexfile);

    return (\@commits, \@updates);
}

sub apply_refupdates()
{
    open UPDATES, $refupdates or die("Cannot open $refupdates: $!\n");
    my @updates = <UPDATES>;
    close UPDATES;
    update_refs(@updates);
    unlink($refupdates);
}

sub invoke_rebase(@)
{
    my @gitcmd = ('rebase');
    push @gitcmd, '-q' if ($quiet);
    push @gitcmd, @_;
    call_git(@gitcmd);
    if ($? == 256) {
        die("*** Contrary to the instructions above, please use\n"
           ."***    git gpick --continue\n"
           ."*** etc., instead of git rebase to resume the operation.\n")
            if (-d $gitdir.'/rebase-apply');
    }
    exit($? >> 8) if ($?);
}

sub rewrite_changes($$$)
{
    my ($root, $commits, $updates) = @_;

    open UPDATES, "> $refupdates" or die("Cannot write $refupdates: $!\n");
    print UPDATES @$updates;
    close UPDATES or die("Failed to write ref update file: $!\n");

    # Abuse git-rebase -i to do the commit replacement.
    # We do that instead of resetting and cherry-picking ourselves, so that
    # the user is faced with a familiar environment when something goes wrong.
    # However, it is slow ...

    END { unlink($todo) if ($todo); }
    $todo = $gitdir.'/gpick-todo';

    open TODO, "> $todo" or die("Cannot write $todo: $!\n");
    print TODO "pick $_\n" foreach (@$commits);
    close TODO or die("Failed to write rebase todo file: $!\n");

    $ENV{GREFRESH_TODO} = $todo;
    $ENV{EDITOR} = $script;
    invoke_rebase('-i', $root);
}

my $editor = $ENV{EDITOR};
if (defined($editor) && ($editor eq $script)) {
    # The script was launched as an $EDITOR from git rebase -i.
    # Replace the todo file and exit.
    rename($ENV{GREFRESH_TODO}, $ARGV[0]);
    exit;
}

parse_arguments(@ARGV);
goto_gitdir();
$refupdates = $gitdir.'/gpick-refupdates';
if (defined($rebase_action)) {
    invoke_rebase($rebase_action);
    apply_refupdates() if ($rebase_action eq '--continue');
    exit;
}
load_config();
config_gerrit();
determine_target();
query_refs();
my ($root, @changes) = get_patches();
@changes = apply_specs($root, @changes);
my @fetches = keys %change2action;
query_gerrit(@fetches);
fetch_patchsets(@fetches);
visit_revs(0, values %change2pushed);
my ($commits, $updates) = adjust_changes(@changes);
if (!$dry_run) {
    if ($check) {
        update_refs(@$updates);
    } else {
        rewrite_changes($root, $commits, $updates);
        apply_refupdates();
    }
}
-------------- next part --------------
#!/usr/bin/env perl
# Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
# Contact: http://www.qt-project.org/legal
#
# You may use this file under the terms of the 3-clause BSD license.
# See the file LICENSE from this package for details.
#

use strict;
use warnings;
no warnings qw(io);

use Carp;
$SIG{__WARN__} = \&Carp::cluck;

use JSON;

sub usage()
{
    print << "EOM";
Usage:
    git gpull [<git-pull options>]|<git-rebase options>

    This command should be used instead of 'git pull --rebase' when
    git-push is being used.

    If the rebase is interrupted due to conflicts, use this command
    with the respective git-rebase option to resume.

Copyright:
    Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
    Contact: http://www.qt-project.org/legal

License:
    You may use this file under the terms of the 3-clause BSD license.
EOM
}

my $debug = 0;
my $verbose = 0;
my $quiet = 0;
my $dry_run = 0;

my $remote;
my $gerrit_ssh;
my $gerrit_project;
my $origin;
my $branch;

my %gitconfig = ();

my %commit2change = ();

my %change2status = ();

sub format_cmd(@)
{
    return join(' ', map { /\s/ ? '"' . $_ . '"' : $_ } @_);
}

sub close_pipe($;$)
{
    close(shift) and return 1;
    die("Closing pipe failed: $!\n") if ($!);
    die("Process crashed with signal $?.\n") if (($? & 128) && $? != 141); # allow SIGPIPIE
    exit($? >> 8) if ($? && shift);
    return 0;
}

sub open_cmd_pipe(@)
{
    print "+ ".format_cmd(@_)."\n" if ($debug);
    open(my $pipe, '-|', @_) or die("Failed to run \"$_[0]\": $!\n");
    return $pipe;
}

sub read_cmd_line(@)
{
    my $pipe = open_cmd_pipe(@_);
    my $line = <$pipe>;
    chomp($line) if (defined($line));
    close_pipe($pipe);
    return $line;
}

sub open_git_pipe(@)
{
    return open_cmd_pipe('git', @_);
}

sub read_git_line(@)
{
    return read_cmd_line('git', @_);
}

sub git_configs($)
{
    my ($key) = @_;
    my $ref = $gitconfig{$key};
    return defined($ref) ? @$ref : ();
}

sub git_config($;$)
{
    my ($key, $dflt) = @_;
    my @cfg = git_configs($key);
    return scalar(@cfg) ? $cfg[-1] : $dflt;
}

sub load_config()
{
    # Read all git configuration at once, as that's faster than repeated
    # git invocations, especially under Windows.
    foreach (`git config --list`) {
        die("Malformed git config output '$_'.\n") if (!/^([^=]+)=(.*)$/);
        push @{$gitconfig{$1}}, $2;
    }

    $remote = git_config('gpush.remote', 'gerrit');
}

sub read_fields($@)
{
    my $fh = shift;
    return 0 if (eof($fh));
    local $/ = "\0";
    for (@_) { chop($_ = <$fh>); }
    return 1;
}

sub get_patches()
{
    my @commits = ();

    my $log = open_git_pipe('log', '--first-parent', '-z', '--pretty=%H%x00%B',
                            "^$origin/$branch", "HEAD");
    while (read_fields($log, my ($commit, $message))) {
        $message =~ /^(.*)$/m;
        my $subject = $1;

        die(format_change($commit, $subject)." has no Change-Id.\n")
            if ($message !~ /^Change-Id: (.+)$/m);
        my $changeid = $1;

        print "-- $commit: $subject\n" if ($debug);

        $commit2change{$commit} = $changeid;
        unshift @commits, $commit;
    }
    close_pipe($log, 1);
    return @commits;
}

sub query_gerrit(@)
{
    my @changes = @_;

    my $status = open_cmd_pipe('ssh', $gerrit_ssh, 'gerrit', 'query', '--format', 'JSON',
                               "project:$gerrit_project", "branch:$branch",
                               '\\('.join(' OR ', @changes).'\\)');
    while (<$status>) {
        print "- $_" if ($debug);
        my $review = decode_json($_);
        defined($review) or die("Cannot decode JSON string '".chomp($_)."'\n");
        my $changeid = $$review{'id'};
        next if (!defined($changeid));
        my $status = $$review{'status'};
        defined($status) or die("Huh?! $changeid has no status?\n");
        $change2status{$changeid} = $status;
    }
    close_pipe($status, 1);
}

sub perform_tail()
{
    load_config();

    $gerrit_ssh = git_config('remote.'.$remote.'.url');
    die("Remote '$remote' does not exist.\n") if (!$gerrit_ssh);
    $gerrit_ssh =~ s/\.git$//;
    die("Remote '$remote' does not use the SSH protocol.\n")
        if ($gerrit_ssh !~ m,^ssh://([^/]+)/(.*)$,);
    $gerrit_ssh = $1;
    $gerrit_project = $2;

    my %living_branches = ();
    my $branches = open_git_pipe("branch");
    while (<$branches>) {
        print "- $_" if ($debug);
        chop;
        next if (!/^([* ]) ([^(]+)$/);  # Ignore detached HEAD
        my ($cur, $ref) = ($1 eq '*', $2);
        my $up = git_config("branch.$ref.merge");
        if (!defined($up)) {
            die("$ref has no tracking branch.\n") if ($cur);
        } else {
            $up =~ s,^refs/heads/,,;
            $living_branches{$up} = 1;
            if ($cur) {
                $branch = $up;
                $origin = git_config("branch.$ref.remote");
                die("$ref has no upstream remote.\n") if (!defined($origin));
            }
        }
    }
    close_pipe($branches, 1);
    die("Not on a branch, cannot proceed.\n") if (!defined($branch));

    # Changes which are on the local branch are excluded from pruning. Obviously.
    my %keep = map { $commit2change{$_} => 1 } get_patches();

    my %zaps = ();
    my %branch_zaps = ();
    my $refs = open_git_pipe('show-ref');  # No way to filter the namespace :(
    local $SIG{PIPE} = "IGNORE";
    while (<$refs>) {
        print "- $_" if ($debug);
        chop;
        if (/^.{40} (refs\/gpush\/([^\/]+)\/([^_]+)_(.*)?)$/) {
            if ($2 eq $branch) {
                push @{$zaps{$3}}, $1 if (!defined($keep{$3}));
            } else {
                push @{$branch_zaps{$2}}, $1 if (!defined($living_branches{$2}));
            }
        }
    }
    close_pipe($refs, 1);

    query_gerrit(keys %zaps) if (%zaps);

    my @gitcmd = ("git", "update-ref", "--stdin");
    print "+ @gitcmd\n" if ($debug);
    open(my $pipe, '|-', @gitcmd) or die("Failed to run @gitcmd: $!\n");
    foreach my $changeid (keys %zaps) {
        my $status = $change2status{$changeid};
        if (defined($status) && ($status ne 'MERGED' && $status ne 'ABANDONED')) {
            # Even changes which are absent from the local branch are pruned
            # only if they are in a terminal state. Otherwise, there is reason
            # to believe that they might be pushed again at a later point.
            print "Keeping $changeid, as it is not MERGED or ABANDONED.\n" if ($verbose);
        } else {
            print "Pruning $changeid.\n" if ($verbose);
            foreach (@{$zaps{$changeid}}) {
                print "> delete $_\n" if ($debug);
                print $pipe "delete $_\n" if (!$dry_run);
            }
        }
    }
    foreach my $ref (keys %branch_zaps) {
        print "Pruning status for orphaned upstream branch $ref.\n" if (!$quiet);
        foreach (@{$branch_zaps{$ref}}) {
            print "> delete $_\n" if ($debug);
            print $pipe "delete $_\n" if (!$dry_run);
        }
    }
    close_pipe($pipe, 1);
}

sub invoke_git(@)
{
    my @gitcmd = ('git', @_);
    push @gitcmd, '-v' if ($verbose);
    push @gitcmd, '-q' if ($quiet);
    print "+ @gitcmd\n" if ($debug);
    my $rv = system(@gitcmd);
    die("Failed to run git: $!\n") if ($rv < 0);
    if ($rv == 256) {
        my $gitdir = read_git_line('rev-parse', '--git-dir');
        exit($? >> 8) if ($?);
        die("*** Contrary to the instructions above, please use\n"
           ."***    git gpull --continue\n"
           ."*** etc., instead of git rebase to resume the pull.\n")
            if (-d $gitdir.'/rebase-apply');
    }
    exit($rv >> 8) if ($rv);
}

my @gitcall = ('pull', '--rebase');
my $do_tail = 1;
while (scalar @ARGV) {
    my $arg = shift @ARGV;

    if ($arg eq "-v" || $arg eq "--verbose") {
        $verbose = 1;
    } elsif ($arg eq "-q" || $arg eq "--quiet") {
        $quiet = 1;
    } elsif ($arg eq "--debug") {
        $debug = 1;
        $verbose = 1;
    } elsif ($arg eq "-n" || $arg eq "--dry-run") {
        $dry_run = 1;
    } elsif ($arg eq "--abort") {
        @gitcall = ('rebase', $arg);
        $do_tail = 0;
    } elsif ($arg eq "--continue" || $arg eq "--skip") {
        @gitcall = ('rebase', $arg);
    } elsif ($arg eq "-?" || $arg eq "--?" || $arg eq "-h" || $arg eq "--help") {
        usage();
        exit 0;
    } else {
        push @gitcall, $arg;
    }
}
die("--quiet and --verbose/--debug are mutually exclusive.\n")
    if ($quiet && $verbose);
invoke_git(@gitcall);
perform_tail() if ($do_tail);


More information about the Development mailing list