summaryrefslogtreecommitdiffstats
path: root/bin/git-gpush
blob: 841a60fbec3540a2e60d685f289dc09689e5f472 (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
#!/usr/bin/perl
# Copyright (C) 2017 The Qt Company Ltd.
# 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 strict;
use warnings;
no warnings qw(io);

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

use File::Spec;
use File::Basename;
use IPC::Open3 qw(open3);

# 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] [+<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.

    If no sha1 or ref-from is 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:
    -d, --draft
        Mark the pushed Changes as drafts. This switch is usually
        unnecessary, as gpush will recognize drafts by "***" in the
        subject. Note that "WIP" is specifically NOT a draft indicator.

    -p, --publish
        Do not mark the pushed Changes as drafts even if they have a
        draft indicator.

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

    -b, --branch
        Specify the git branch to push for. If not specified, the
        upstream branch for 'ref-from' is used as the target branch.

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

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.
        Defaults to 'gerrit' 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.
    Contact: http://www.qt.io/licensing/

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 = "gerrit";
my $ref_from = "HEAD";
my $ref_to = "";
my $draft = 0;

my %aliases;

my @reviewers;
my @CCs;

my %gitconfig;

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

use constant {
    NUL_STDIN => 0,
    USE_STDIN => 1,
    # FWD_STDIN is not needed
    NUL_STDOUT => 0,
    USE_STDOUT => 4,
    FWD_STDOUT => 8,
    NUL_STDERR => 0,
    # USE_STDERR is not needed
    FWD_STDERR => 32,
    FWD_OUTPUT => 40,
    SILENT_STDIN => 64,  # Suppress debug output for stdin
    SOFT_FAIL => 256,    # A non-zero exit from the process is not fatal
    DRY_RUN => 512       # Don't actually run the command if $dry_run is true
};

sub open_process($@)
{
    my ($flags, @cmd) = @_;
    my %process;

    $flags &= ~DRY_RUN if (!$dry_run);
    $process{flags} = $flags;
    if ($flags & DRY_RUN) {
        print "+ ".format_cmd(@cmd)." [DRY]\n" if ($debug);
        return \%process;
    }
    my $cmd = format_cmd(@cmd);
    $process{cmd} = $cmd;
    my ($in, $out, $err);
    if ($flags & USE_STDIN) {
        $in = \$process{stdin};
    } else {
        $in = \'<&NUL';
    }
    if ($flags & USE_STDOUT) {
        $out = \$process{stdout};
    } elsif ($flags & FWD_STDOUT) {
        $out = \'>&STDOUT';
    } else {
        $out = \'>&NUL';
    }
    if ($flags & FWD_STDERR) {
        $err = \'>&STDERR';
    } else {
        $err = \'>&NUL';
    }
    print "+ $cmd\n" if ($debug);
    open(NUL, '>'.File::Spec->devnull()) or die("Failed to open bitbucket: $!\n");
    eval { $process{pid} = open3($$in, $$out, $$err, @cmd); };
    die("Failed to run \"$cmd[0]\": $!\n") if ($@);
    close(NUL);
    return \%process;
}

sub close_process($)
{
    my ($process) = @_;

    if ($$process{flags} & DRY_RUN) {
        $? = 0;
        return 0;
    }
    my $cmd = $$process{cmd};
    if ($$process{stdout}) {
        close($$process{stdout}) or die("Failed to close read pipe of '$cmd': $!\n");
    }
    waitpid($$process{pid}, 0) or die("Failed to wait for '$cmd': $!\n");
    if ($? & 128) {
        die("'$cmd' crashed with signal ".($? & 127).".\n") if ($? != 141); # allow SIGPIPE
        $? = 0;
    } elsif ($? && !($$process{flags} & SOFT_FAIL)) {
        exit($? >> 8);
    }
    return 0;
}

# Write any number of lines to the process' stdin.
# The input is expected to already contain trailing newlines.
# This function must be called exactly once iff USE_STDIN is used.
# Note that this will deadlock with USE_STDOUT if the process outputs
# too much before all input is written.
sub write_process($@)
{
    my ($process, @input) = @_;

    my $stdin = $$process{stdin};
    my $silent = ($$process{flags} & SILENT_STDIN);
    my $dry = ($$process{flags} & DRY_RUN);
    local $SIG{PIPE} = "IGNORE";
    foreach (@input) {
        print "> $_" if ($debug && !$silent);
        print $stdin $_ if (!$dry);
    }
    $dry or close($stdin) or die("Failed to close write pipe of '$$process{cmd}': $!\n");
}

# Read a line from the process' stdout.
sub read_process($)
{
    my ($process) = @_;

    my $fh = $$process{stdout};
    $_ = <$fh>;
    if (defined($_)) {
        chomp;
        print "- $_\n" if ($debug);
    }
    return $_;
}

# Read any number of null-terminated fields from the process' stdout.
sub read_fields($@)
{
    my $process = shift;
    my $fh = $$process{stdout};
    return 0 if (eof($fh));
    local $/ = "\0";
    for (@_) { chop($_ = <$fh>); }
    return 1;
}

# The equivalent of system().
sub run_process($@)
{
    my ($flags, @cmd) = @_;

    close_process(open_process($flags, @cmd));
}

# The equivalent of popen("r").
sub open_cmd_pipe($@)
{
    my ($flags, @cmd) = @_;

    return open_process(USE_STDOUT | FWD_STDERR | $flags, @cmd);
}

# Run the specified command and try to read exactly one line from its stdout.
sub read_cmd_line($@)
{
    my ($flags, @cmd) = @_;

    my $proc = open_cmd_pipe($flags, @cmd);
    read_process($proc);
    close_process($proc);
    return $_;
}

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 "-r" || $arg eq "--remote") {
            die("--remote needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $remote = shift @_;
        } elsif ($arg eq "-b" || $arg eq "--branch") {
            die("--branch needs an argument.\n") if (!@_ || ($_[0] =~ /^-/));
            $ref_to = shift @_;
        } 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 !~ /^\-/) {
            if ($arg =~ /(.*):(.*)/) {
                if (length($1)) {
                    $ref_from = $1;
                    print STDERR "Warning: Specifying <ref-from>: is deprecated.".
                                 " Use just <ref-from> instead.\n";
                }
                if (length($2)) {
                    $ref_to = $2;
                    print STDERR "Warning: Specifying :<ref-to> is deprecated.".
                                 " Use --branch instead.\n";
                }
            } else {
                $ref_from = $arg;
            }
        } else {
            die("Unrecognized option '$arg'.\n");
        }
    }

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

    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
    my $in_aliases = 1;
    foreach my $line (fileContents("$script_path/.git-gpush-aliases")) {
        chomp $line;
        $line =~ s,(#|//).*$,,;             # Remove any comments
        if ($line =~ /^\[([^]]+)\]/) {
            if ($1 eq "aliases") {
                $in_aliases = 1;
            } elsif ($1 eq "config") {
                $in_aliases = 0;
            } else {
                die("Unrecognized section '$1' in alias file.\n");
            }
        } elsif ($line =~ /^\s*([^ =]+)\s*=\s*(.*?)\s*$/) {  # Capture the value
            if ($in_aliases) {
                for my $alias (split(/,/, $1)) {
                    $aliases{$alias} = $2;
                }
            } else {
                push @{$gitconfig{"gpush.$1"}}, $2;
            }
        }
    }

    # Read all git configuration at once, as that's faster than repeated
    # git invocations, especially under Windows.
    my $cfg = open_cmd_pipe(0, 'git', 'config', '-l', '-z');
    while (read_fields($cfg, my $entry)) {
        $entry =~ /^([^\n]+)\n(.*)$/;
        push @{$gitconfig{$1}}, $2;
    }
    close_process($cfg);

    $remote = git_config('gpush.remote', $remote);
    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 goto_gitdir()
{
    my $cdup = read_cmd_line(0, 'git', 'rev-parse', '--show-cdup');
    chdir($cdup) unless $cdup eq "";
}

# Find _the_ branch the specified commit lives on. This can be the current
# branch (and other branches are ignored), or _one_ other branch.
sub determine_branch($)
{
    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;
}

sub determine_target()
{
    # Validate the source commit, to avoid confusing errors later.
    run_process(FWD_STDERR, "git", "rev-parse", $ref_from, '--');

    # Detect upstream branch if ref-to is not set
    if ($ref_to eq "") {
        # First, try to extract a branch name directly.
        my $ref = $ref_from;
        $ref =~ s/[~^].*$//;
        if ($ref eq "HEAD") {
            my $sref = read_cmd_line(SOFT_FAIL, "git", "symbolic-ref", "-q", "HEAD");
            $ref = $sref if (!$?);
        }
        $ref =~ s,^refs/heads/,,;
        run_process(SOFT_FAIL, "git", "rev-parse", "--verify", "-q", "refs/heads/".$ref);
        if ($?) {
            # Next, try to deduce a branch from the commit.
            $ref = determine_branch($ref_from);
            die("Cannot deduce source branch for $ref_from.\n") if (!defined($ref));
        }
        $ref_to = git_config("branch.$ref.merge");
        die("$ref has no upstream branch.\n") if (!defined($ref_to));
        $ref_to =~ s,^refs/heads/,,;
    }
}

sub push_patches()
{
    if (!$draft) {
        $_ = read_cmd_line(0, 'git', 'log', '--pretty=%s', '-1', $ref_from);
        $draft = 1 if (/\*{3}|^(.)\1*$/i);
    }

    print "Pushing $ref_from for $ref_to on $remote ...\n" if (!$quiet);

    my @push_options;
    push @push_options, map { "r=$_" } @reviewers;
    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, "$ref_from:refs/".($draft > 0 ? 'drafts' : 'for')."/$ref_to";

    run_process(FWD_OUTPUT, @gitcmd);
}

load_config();
parse_arguments(@ARGV);
goto_gitdir();
determine_target();
push_patches();