summaryrefslogtreecommitdiffstats
path: root/bin/git-gpush
blob: bf1ca8196cc325cd6f68527ccba8697066a6b3b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
#!/usr/bin/perl
# Copyright (C) 2017 The Qt Company Ltd.
# Copyright (C) 2019 Oswald Buddenhagen
# Contact: http://www.qt.io/licensing/
#
# You may use this file under the terms of the 3-clause BSD license.
# See the file LICENSE from this package for details.
#

use v5.14;
use strict;
use warnings;

our ($script, $script_path);
BEGIN {
    use Cwd qw(abs_path);
    if ($^O eq "msys") {
        $0 =~ s,\\,/,g;
        $0 =~ s,^(.):/,/$1/,g;
    }
    $script_path = $script = abs_path($0);
    $script_path =~ s,/[^/]+$,,;
    unshift @INC, $script_path;
}
use git_gpush;

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] [from] [+<reviewer>] [=<CC user>]

    Pushes Changes to Gerrit and adds reviewers and CC to the PatchSets.

Description:
    This program is used to push PatchSets to Gerrit, and at the same
    time add reviewers and CCs to the PatchSets pushed.

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

    The pushed commits are listed by default. Conforming with Gerrit,
    these are the commits which are not on any branch of the pushed
    branch's upstream remote.

    'From' may be specified as either a ref, a SHA1, or a Gerrit Change-Id,
    possibly abbreviated. Git rev-spec suffixes like '~2' are allowed; if
    only a suffix is specified, it is understood to be relative to 'HEAD'.
    If 'from' is not specified, 'HEAD' is used.

    Note that this program can be used in the middle of an interactive
    rebase, to push out the amended commits instantly.

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

    -r, --remote
        Specify the git remote to push to.

    -b, --branch
        Specify the git branch to push for. If not specified, 'from's
        upstream branch is used as the target branch.
        This setting persists for the series, even when it grows.

    -fb, --force-branch
        Push for specified branch despite the pushed Changes having
        been pushed previously, but only for different branches.

    -t, --topic
        Specify the Gerrit topic name for the pushed Changes.
        This setting persists for the series, even when it grows.

    -l, --list
        Report all Changes that would be pushed, then quit.
        This is a purely off-line operation.

    -ll, --list-online
        Report all Changes that would be pushed, then quit.
        The Changes are annotated with state information from Gerrit.

    --aliases
        Report all registered aliases and quit.

    -n, --dry-run
        Do everything except actually pushing commits and updating state.
        This is useful mostly for debugging.

    -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.

Configuration:
    This program uses options from the git configuration. All its keys
    use the 'gpush.' prefix. Consequently, to configure any option, you
    can use a command like this:
        git config --global gpush.<the option> <the value>
    If you want it to be local to the current repository, just drop the
    --global option. The following options are supported:

    alias.<alias>
        An alias definition. The value is a comma-separated list of Gerrit
        login names and/or email addresses, so it's possible to map, for
        example, IRC nicknames or entire teams. Note that git config keys
        are constrained regarding allowed characters, so it is impossible
        to map some IRC nicks via git configuration; see below for an
        alternative.

    remote
        The default git remote to use for pushing to Gerrit.
        When not configured, 'gerrit' is used if present, otherwise the
        pushed branch's upstream remote is used.

    upstream
        The git remote to assume to be the upstream if the pushed branch
        does not have a remote-tracking branch configured.
        Defaults to 'origin' or the only present remote if not configured.

    In addition to the git configuration (which takes precedence), the file
    .git-gpush-aliases located next to this program is also read. It may
    contain two sections: 'config' where you can use the options specified
    above, and 'aliases'. The latter works just like alias definitions via
    the git configuration, except that:
    - The alias name itself may also be a comma-separated list, thus
      supporting users with multiple handles.
    - Most characters are permitted in the alias names.

Copyright:
    Copyright (C) 2017 The Qt Company Ltd.
    Copyright (C) 2019 Oswald Buddenhagen
    Contact: http://www.qt.io/licensing/

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

my $from;
my $ref_to;
my $force_branch = 0;
my $topic;
my $force = 0;
my $list_only = 0;
my $list_online = 0;

my @reviewers;
my @CCs;

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 "-f" || $arg eq "--force") {
            $force = 1;
        } elsif ($arg eq "-r" || $arg eq "--remote") {
            fail("--remote needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $remote = shift @_;
        } elsif ($arg eq "-t" || $arg eq "--topic") {
            fail("--topic needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $topic = shift @_;
        } elsif ($arg eq "-b" || $arg eq "--branch") {
            fail("--branch needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $ref_to = shift @_;
        } elsif ($arg eq "-fb" || $arg eq "--force-branch") {
            $force_branch = 1;
        } elsif ($arg eq "-l" || $arg eq "--list") {
            $list_only = 1;
        } elsif ($arg eq "-ll" || $arg eq "--list-online") {
            $list_only = 1;
            $list_online = 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 =~ /^\+(.+)/) {
            push @reviewers, split(/,/, lookup_alias($1));
        } elsif ($arg =~ /^\=(.+)/) {
            push @CCs, split(/,/, lookup_alias($1));
        } elsif ($arg !~ /^\-/) {
            fail("Specifying multiple sources is currently not supported.\n")
                if (defined($from));
            if ($arg =~ /(.*):(.*)/) {
                if (length($1)) {
                    $from = $1;
                    print STDERR "Warning: Specifying <from>: is deprecated.".
                                 " Use just <from> instead.\n";
                }
                if (length($2)) {
                    $ref_to = $2;
                    print STDERR "Warning: Specifying :<ref-to> is deprecated.".
                                 " Use --branch instead.\n";
                }
            } else {
                $from = $arg;
            }
        } else {
            fail("Unrecognized option '$arg'.\n");
        }
    }

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

    if (!defined($from)) {
        $from = "HEAD";
    }

    my $push_specific =
            @reviewers || @CCs || $force || $force_branch
            || defined($remote) || defined($ref_to) || defined($topic);

    if ($list_only) {
        fail("--list/--list-online is incompatible with --quiet/--verbose.\n")
            if ($quiet || ($verbose && !$debug));
        fail("--list/--list-online is incompatible with push-modifying options.\n")
            if ($push_specific);
    }
}

sub process_config()
{
    load_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 set_group_error($$$)
{
    my ($group, $style, $error) = @_;

    ($$group{error_style}, $$group{error}) = ($style, $error);
}

sub caption_group($)
{
    my ($group) = @_;

    my $changes = $$group{changes};
    my $to = $$group{branch};
    my $tos = defined($to) ? " for $to" : "";
    my $tpc = $$group{topic};
    my $tpcs = length($tpc) ? ", topic '$tpc'" : "";
    my ($pfx, $rmt) = $list_only
                          ? ("Series of",
                             ($list_online && length($tos)) ? " on '$remote'" : "")
                          : ("Pushing", " to '$remote'");
    return (sprintf("%s %d Change(s)%s%s%s:",
                    $pfx, int(@$changes), $tos, $rmt, $tpcs),
            $changes);
}

sub report_pushed_group($$)
{
    my ($reports, $group) = @_;

    my ($title, $changes) = caption_group($group);
    report_flowed($reports, $title);
    report_local_changes($reports, $changes);
    my $error = $$group{error};
    report_text($reports, $$group{error_style}, $error) if (defined($error));
}

sub report_pushed_changes($)
{
    my ($group) = @_;

    my @reports;
    report_pushed_group(\@reports, $group);
    return \@reports;
}

# Determine a singular value for a particular attribute from the Changes
# in a series. Conflicting values are an error.
sub aggregate_property($$$$)
{
    my ($group, $prop_name, $get_prop, $get_ann) = @_;

    my $changes = $$group{changes};
    my %prop_map = map { $_ => 1 } grep { $_ } map { $get_prop->($_) } @$changes;
    my @props = keys %prop_map;
    if (@props > 1) {
        foreach my $change (@$changes) {
            my $p = $get_prop->($change);
            $$change{annotation} = $get_ann->($p) if (defined($p));
        }
        set_group_error($group, 'flowed',
                        "Changes were previously pushed with mixed ${prop_name}s."
                        ." Please specify one.");
        fail_formatted(report_pushed_changes($group));
    }
    if (@props) {
        my $p = $props[0];
        print "Re-using $prop_name '$p'.\n" if ($debug);
        return $p;
    }
    return undef;
}

# Find _the_ branch the specified commit lives on. This can be the current
# branch (and other branches are ignored), or _one_ other branch.
sub branch_for_commit($)
{
    my ($commit) = @_;

    my $curbranch;
    my @otherbranches;
    my $branches = open_cmd_pipe(0, "git", "branch", "--contains", $commit);
    while (read_process($branches)) {
        if (/^\* \(/) {
            # New git versions will tell us the currently rebased branch.
            if (/^\* \(no branch, rebasing (.*)\)$/) {
                $curbranch = $1;
            }
            last;
        } elsif (/^\* (.*)$/) {
            $curbranch = $1;
            last;
        } elsif (/^  (.*)$/) {
            push @otherbranches, $1;
        }
    }
    close_process($branches);
    if (!defined($curbranch)) {
        # If the commit is not on the current branch, see if it is on _one_
        # other branch with an upstream branch.
        my @goodbranches;
        foreach my $other (@otherbranches) {
            push @goodbranches, $other if (defined(git_config("branch.$other.merge")));
        }
        $curbranch = $goodbranches[0] if (@goodbranches == 1);
    }
    return $curbranch;
}

# Extract the local branch from the specified source.
# There are four possibilities where the source revision may be located:
# - On any named branch in a regular state.
#   The branch is usable up to its tip, which may be pointed to by HEAD.
# - On the current named branch mid-rebase.
#   The branch is pointed to by HEAD, and is usable only up to this point.
# - On a detached head, possibly mid-rebase.
#   HEAD is the only reference and thus also the tip. There is no name.
# - Nowhere, except possibly in the reflog.
#   It is the tip, as there are no references. There is no name.
# Note that commits specified by SHA1 which live on exactly one named
# branch fall into the first case.
sub determine_local_branch($)
{
    my ($source) = @_;

    # First, try to extract a branch name directly.
    $local_branch = ($source =~ s/[~^].*$//r);
    if ($local_branch eq "HEAD") {
        my $sref = read_cmd_line(SOFT_FAIL, "git", "symbolic-ref", "-q", "HEAD");
        $local_branch = $sref if (!$?);
    }
    $local_branch =~ s,^refs/heads/,,;
    if (!defined($local_refs{$local_branch})) {
        # Next, try to deduce a branch from the commit.
        $local_branch = branch_for_commit($source);
    }
    setup_remotes($source);
    set_gerrit_config($remote);
}

# Determine the target branch for the given series.
# The local branch's upstream branch will be used, unless a target branch
# has been specified by the user.
sub determine_remote_branch($)
{
    my ($group) = @_;

    my $br = $ref_to;
    if (!defined($br)) {
        $br = aggregate_property($group, 'target',
                                 sub { $_[0]->{tgt} },
                                 sub { '  => '.$_[0] });
    }
    if (!defined($br)) {
        fail("Cannot deduce source branch for $from. Please use --branch.\n")
            if (!defined($local_branch));
        $br = git_config("branch.$local_branch.merge");
        fail("$local_branch has no upstream branch. Please use --branch.\n")
            if (!defined($br));
        $br =~ s,^refs/heads/,,;
    }
    $$group{branch} = $br;
}

sub determine_topic($)
{
    my ($group) = @_;

    my $tpc = $topic;
    if (!defined($tpc)) {
        # No topic was specified, so deduce it from the previous push(es).
        $tpc = aggregate_property($group, 'topic',
                                  sub { $_[0]->{topic} },
                                  sub { '  /'.$_[0] });
    }
    $$group{topic} = $tpc;
}

sub initialize_get_changes()
{
    my $commit = parse_local_rev($from, SPEC_TIP);
    if (!defined($commit)) {
        # The revspec might refer to a Change-Id.
        # We need a place to start from. We try only the current local branch -
        # trying multiple branches would be expensive, potentially ambiguous
        # (as the same Change-Id can exist multiple times), and probably not
        # very useful to start with.
        determine_local_branch('HEAD');
        # Get the pool of Changes to search in, and later to push from.
        analyze_local_branch('HEAD') or
            fail("No local Changes (from HEAD).\n");
        # Now try again.
        $commit = parse_local_rev_id($from, SPEC_TIP);
    } else {
        # We did not need to visit the current branch to find the tip,
        # so determine the branch from the tip now.
        $from = "HEAD".$from if ($from =~ /^[~^]/);
        determine_local_branch($from);
        # Get the pool of Changes to push from.
        analyze_local_branch($from) or
            fail("No local Changes (from $from).\n");
    }
    return $commit;
}

sub finalize_get_changes($)
{
    my ($changes) = @_;

    my %group = (changes => $changes);
    return \%group;
}

# Get the list of local commits to push.
sub get_changes()
{
    my $tip = initialize_get_changes();
    # Assemble the series of Changes to push from the pool.
    my $changes = changes_from_commits(get_commits_free($tip));
    fail("Specified commit range is empty.\n") if (!@$changes);
    my $group = finalize_get_changes($changes);
    return $group;
}

sub check_merge($$);

sub check_merge($$)
{
    my ($parents, $failed) = @_;

    my $good = 1;
    foreach my $parentid (@$parents) {
        my $parent = $commit_by_id{$parentid};
        # If the parent is upstream, the merge is good.
        next if (!$parent);

        my $rparents = $$parent{parents};
        # Merges of (good) merges are also good.
        next if (@$rparents > 1 && check_merge($rparents, $failed));

        push @$failed, $parent;
        $good = 0;
    }
    return $good;
}

# Ensure that we don't implicitly create new PatchSets for non-1st
# parents of merges, as these would most likely target the wrong branch.
# TODO: this could be handled more nicely by doing recursive pushes
# instead of bailing out.
sub parents_pushed($$)
{
    my ($parents, $failed) = @_;

    foreach my $parentid (@$parents[1 .. $#$parents]) {
        next if (defined($gerrit_info_by_sha1{$parentid}));  # Gerrit knows it
        my $parent = $commit_by_id{$parentid};
        next if (!$parent);  # Already upstream
        push @$failed, $parent;
    }
    return !@$failed;
}

sub check_merges($)
{
    my ($group) = @_;

    foreach my $change (@{$$group{changes}}) {
        my $commit = $$change{local};
        my $parents = $$commit{parents};
        if (@$parents > 1) {
            my (@failed, $header);
            if (!check_merge($parents, \@failed)) {
                $header = ",----- Merge of non-upstream commit(s) -----\n";
                set_group_error($group, 'fixed',
                                "Maybe you forgot to use git pull's --rebase option?\n");
            } elsif (!parents_pushed($parents, \@failed)) {
                $header = ",----- Parent merge(s) not pushed yet ------\n";
                set_group_error($group, 'fixed', "Please push the parent(s) first.\n");
            } else {
                next;
            }
            $$change{annotation} = '  [FAIL]';
            set_change_error($change, 'fixed',
                             $header.format_commits(\@failed, "| ")
                             ."`-------------------------------------------\n");
            fail_formatted(report_pushed_changes($group));
        }
    }
}

# Assign each local Change to a matching remote Change if possible,
# on the way complaining about creating duplicates.
sub map_remote_changes($)
{
    my ($group) = @_;

    my $changes = $$group{changes};
    my $br = $$group{branch};
    my (@bad_chg, %bad_br);
    foreach my $change (@$changes) {
        my $gis = $gerrit_infos_by_id{$$change{id}};
        next if (!$gis);
        my ($good, @bad);
        foreach my $gi (@$gis) {
            if ($$gi{branch} eq $br) {
                $good = $gi;
            } elsif ($$gi{status} ne "MERGED") {
                # MERGED Changes are ignored, to avoid false positives for cherry-picks.
                push @bad, $gi;
            }
        }
        if ($good) {
            $$change{gerrit} = $good;
            # This overrides the current value, which is just a cache.
            $$change{topic} = $$good{topic};
        } elsif (@bad && !$force_branch) {
            my @bbr = map { $$_{branch} } @bad;
            $$change{annotation} = '  ['.join(" ", sort @bbr).']';
            push @bad_chg, $change;
            $bad_br{$_} = 1 foreach (@bbr);
        }
    }
    if (@bad_chg) {
        my $reports = report_pushed_changes($group);
        my $tpfx = (@bad_chg == @$changes) ? "The" : "Some of the";
        my $tsfx = (keys(%bad_br) == 1) ? "a different branch" : "different branches";
        report_fixed($reports,
                     "$tpfx Change(s) were previously pushed only for $tsfx.\n",
                     "Please move them server-side, push for a matching branch,\n",
                     "or use --force-branch to continue nonetheless.\n");
        fail_formatted($reports);
    }
}

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

sub classify_changes_offline($)
{
    my ($group) = @_;

    foreach my $change (@{$$group{changes}}) {
        my $pushed = $$change{pushed};
        if (defined($pushed)) {
            my $commit = $$change{local}{id};
            if ($commit eq $pushed) {
                $$change{freshness} = UNMODIFIED;
            } else {
                $$change{freshness} = MODIFIED;
            }
        } else {
            $$change{freshness} = NEW;
        }
    }
}

my $have_rejected = 0;
my $have_modified = 0;
my $need_force = 0;

sub classify_changes_online($)
{
    my ($group) = @_;

    foreach my $change (@{$$group{changes}}) {
        my $commit = $$change{local};
        my $sha1 = $$commit{id};
        my $ginfo = $$change{gerrit};
        my $status = $ginfo && $$ginfo{status};
        my $curr_sha1 = $ginfo && $$ginfo{revs}[-1]{id};
        my $chg_revs = $ginfo && $$ginfo{rev_by_id};
        my $this_rev = $chg_revs && $$chg_revs{$sha1};
        if ($this_rev) {
            $$change{patchset} = $$this_rev{ps};
            $$change{freshness} =
                ($status eq 'MERGED') ? MERGED :
                ($sha1 eq $curr_sha1) ? UNMODIFIED : OUTDATED;
        } elsif ($ginfo && ($status ne 'NEW')) {
            # We are attempting a push which Gerrit will reject anyway.
            my $err;
            if ($status eq 'MERGED') {
                $err = "Change is already MERGED; please pull/rebase.";
            } elsif ($status eq 'STAGED' || $status eq 'INTEGRATING') {
                $err = "Change is currently $status; cannot proceed.";
            } elsif ($status eq 'DEFERRED' || $status eq 'ABANDONED') {
                $err = "Change is $status; please restore it first.";
            } else {
                $err = "Change is in unknown state '$status'. I'm stumped. :}";
            }
            set_change_error($change, 'oneline', $err);
            $$change{freshness} = REJECTED;
            $have_rejected++;
        } else {
            # Finally, check whether the current PatchSet on Gerrit meets our
            # expectations, so we don't accidentally play ping-pong.
            my $pushed = $$change{pushed};
            if ($ginfo) {
                if (!defined($pushed) || ($pushed ne $curr_sha1)) {
                    my $pushed_rev = defined($pushed) && $$chg_revs{$pushed};
                    $$change{patchset} = $pushed_rev ? $$pushed_rev{ps} : "?";
                    $$change{freshness} = FORCE;
                    $need_force = 1;
                } else {
                    $$change{freshness} = MODIFIED;
                }
            } else {
                if (defined($pushed)) {
                    $$change{freshness} = MISSING;
                    $need_force = 1;
                } else {
                    $$change{freshness} = NEW;
                }
            }
            $have_modified++;
        }
    }
}

sub annotate_changes($)
{
    my ($group) = @_;

    foreach my $change (@{$$group{changes}}) {
        my @attribs;
        # Changes in the 'modified' state (that is, the ones for which pushing
        # actually has an effect) are annotated, while 'unmodified' ones are not.
        # This behavior has been chosen after much deliberation following the
        # principle that "no-op" should be silent, despite the fact that "doing
        # nothing" is a diversion from what a regular git push would do, and is
        # thus potentially confusing - but as having no modified changes at all
        # leads to an additional message, the less noisy output (assuming that
        # most Changes are usually not modified) seems most sensible.
        my $freshness = $$change{freshness};
        if ($freshness ne UNMODIFIED) {
            $freshness = "PS$$change{patchset}/$freshness"
                if ($freshness eq OUTDATED || $freshness eq FORCE);
            push @attribs, $freshness;
        }
        $$change{annotation} = '  ['.join('; ', @attribs).']'
            if (@attribs);
    }
}

sub make_listing($)
{
    my ($group) = @_;

    annotate_changes($group);
    return report_pushed_changes($group);
}

sub show_changes($)
{
    my ($group) = @_;

    print format_reports(make_listing($group));
}

sub fail_push($@)
{
    my ($group, @msgs) = @_;

    my $reports = make_listing($group);
    report_fixed($reports, @msgs);
    fail_formatted($reports);
}

sub print_errors($)
{
    my ($group) = @_;

    fail_push($group, "Giving up - push is going to be rejected.\n")
        if ($have_rejected);
    fail_push($group, "Local state is out of sync with Gerrit.\n",
                      "Please specify --force to push nonetheless.\n")
        if ($need_force && !$force);
}

sub add_if_unmodified($$)
{
    my ($change, $list) = @_;

    my $freshness = $$change{freshness};
    if (($freshness eq UNMODIFIED) || ($freshness eq OUTDATED)) {
        push @$list, $$change{local}{id};
    }
}

sub prepare_meta($)
{
    my ($group) = @_;

    my @invite_list;
    my (%invite_rvrs, %invite_ccs);
    if (@reviewers || @CCs) {
        foreach my $change (@{$$group{changes}}) {
            my $ginfo = $$change{gerrit};
            my $rvrs = $ginfo ? $$ginfo{reviewers} : {};
            my $any;
            foreach my $rvr (@reviewers) {
                if (($$rvrs{$rvr} // RVRTYPE_NONE) != RVRTYPE_REV) {
                    $invite_rvrs{$rvr} = 1;
                    $any = 1;
                }
            }
            foreach my $cc (@CCs) {
                if (($$rvrs{$cc} // RVRTYPE_NONE) != RVRTYPE_CC) {
                    $invite_ccs{$cc} = 1;
                    $any = 1;
                }
            }
            # Can't add reviewers to unmodified Changes with a push.
            add_if_unmodified($change, \@invite_list) if ($any);
        }
    }
    $$group{add_rvrs} = [ keys %invite_rvrs ];
    $$group{add_ccs} = [ keys %invite_ccs ];
    $$group{invite_list} = \@invite_list;

    my @topic_list;
    my $tpc = $$group{topic};
    if (defined($tpc)) {
        my $any;
        foreach my $change (@{$$group{changes}}) {
            next if ($tpc eq ($$change{topic} // ""));
            $any = 1;
            # Can't set topic of unmodified Changes with a push.
            add_if_unmodified($change, \@topic_list);
        }
        $$group{topic} = undef if (!$any);
    }
    $$group{topic_list} = \@topic_list;
}

sub push_changes($)
{
    my ($group) = @_;

    my $from = $$group{changes}[-1];
    my $tip = $$from{local}{id};
    my $to = $$group{branch};
    my $tpc = $$group{topic};
    my ($rvrs, $ccs) = ($$group{add_rvrs} // [], $$group{add_ccs} // []);

    my @push_options;
    push @push_options, "topic=$tpc" if (defined($tpc));
    push @push_options, map { "r=$_" } @$rvrs;
    push @push_options, map { "cc=$_" } @$ccs;

    my @gitcmd = ("git", "push");
    push @gitcmd, '-v' if ($verbose);
    push @gitcmd, '-q' if ($quiet);
    push @gitcmd, '-n' if ($dry_run);
    push @gitcmd, map { ("-o", "$_") } @push_options;
    push @gitcmd, $remote, "$tip:refs/for/$to";

    run_process(FWD_OUTPUT, @gitcmd);
}

sub update_unpushed($)
{
    my ($group) = @_;

    my $invite_list = $$group{invite_list};
    if (@$invite_list) {
        print "Inviting reviewers/CCs to unmodified commit(s) ...\n";
        my ($rvrs, $ccs) = ($$group{add_rvrs} // [], $$group{add_ccs} // []);
        my @rlist;
        foreach my $rvr (@$rvrs) {
            push @rlist, { "reviewer" => $rvr, "state" => "REVIEWER" };
        }
        foreach my $cc (@$ccs) {
            push @rlist, { "reviewer" => $cc, "state" => "CC" };
        }
        my $json = { "reviewers" => \@rlist };
        my $pipe = open_process(USE_STDIN | FWD_OUTPUT | DRY_RUN,
                                'ssh', @gerrit_ssh, 'gerrit', 'review', '--json',
                                @$invite_list);
        write_process($pipe, encode_json($json)."\n");
        close_process($pipe);
    }

    my $topic_list = $$group{topic_list};
    if (@$topic_list) {
        # TODO: This can be done with the REST API.
        print "Warning: Cannot set topic on unmodified commits.\n";
    }
}

sub update_state($)
{
    my ($group) = @_;

    my ($branch, $tpc) = ($$group{branch}, $$group{topic});
    # Setting an empty topic clears the previous topic from the server.
    $tpc = undef if (defined($tpc) && !length($tpc));
    foreach my $change (@{$$group{changes}}) {
        my $sha1 = $$change{local}{id};
        if (($$change{pushed} // "") ne $sha1) {
            $$change{pushed} = $sha1;
            $$change{topic} = $tpc;
        }
        $$change{tgt} = $branch;
    }
}

sub execute_pushing()
{
    my $online = !$list_only || $list_online;
    my $group = get_changes();
    my $pushed_changes = $$group{changes};
    if ($online) {
        my @queries = map { "change:".$$_{id} } @$pushed_changes;
        push @queries,
                map { "commit:".$_ }
                # Only ones that are not upstream yet.
                grep { $commit_by_id{$_} }
                # Only 2nd+ parents of local commits.
                map { @$_ > 1 ? @$_[1 .. $#$_] : () }
                map { $$_{local}{parents} } @$pushed_changes;
        my @args;
        push @args, "--all-reviewers" if (@reviewers || @CCs);
        query_gerrit(\@queries, \@args);
    }
    determine_remote_branch($group);
    if ($online) {
        check_merges($group);
        map_remote_changes($group);
    }
    determine_topic($group);
    if ($online) {
        classify_changes_online($group);
    } elsif (!$quiet) {
        classify_changes_offline($group);
    }
    if ($list_only) {
        show_changes($group);
        print "Not pushing - list mode.\n" if ($debug);
    } else {
        print_errors($group);
        show_changes($group) if (!$quiet);
        prepare_meta($group);
        if ($have_modified) {
            push_changes($group);
        } else {
            print "No modified commits - nothing to push.\n" if (!$quiet);
        }
        update_unpushed($group);

        # This makes sense even if no modified commits are pushed
        # (e.g., syncing state after a dumb push).
        update_state($group);
    }
}

process_config();
parse_arguments(@ARGV);
goto_gitdir();
load_state();
execute_pushing();
save_state($dry_run);