summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
authorOswald Buddenhagen <oswald.buddenhagen@qt.io>2017-08-24 21:20:44 +0200
committerOswald Buddenhagen <oswald.buddenhagen@gmx.de>2020-02-28 16:32:40 +0000
commita5b05c44cc9afb7e08af0742d32e3149c01dbbaa (patch)
treeb4a2835037fa6f51fc64e7183b3ccb4bd09c4ce0 /bin
parent0910ec06e945df94dae4c0a296376ef6a4bcf155 (diff)
downloadqtrepotools-a5b05c44cc9afb7e08af0742d32e3149c01dbbaa.tar.gz
gpush: remember last pushed sha1 of each Change
for now, we use this information only for annotating the Change status. each pushed commit is saved to a ref, which is currently unnecessary, as we only compare sha1s. however, later on we'll also need the commit for comparing contents, so avoid the subsequent state format change. Change-Id: I839b59ed8c1a2e0b0cb1395bfe7e1da84f3035de Reviewed-by: Oswald Buddenhagen <oswald.buddenhagen@gmx.de>
Diffstat (limited to 'bin')
-rwxr-xr-xbin/git-gpush77
-rw-r--r--bin/git_gpush.pm221
2 files changed, 287 insertions, 11 deletions
diff --git a/bin/git-gpush b/bin/git-gpush
index a31737a..4532ac1 100755
--- a/bin/git-gpush
+++ b/bin/git-gpush
@@ -64,7 +64,7 @@ Options:
Report all registered aliases and quit.
-n, --dry-run
- Do everything except actually pushing any commits.
+ Do everything except actually pushing commits and updating state.
This is useful mostly for debugging.
-v, --verbose
@@ -356,10 +356,63 @@ sub get_changes()
return $group;
}
+use constant {
+ NEW => 'NEW',
+ MODIFIED => 'MODIFIED',
+ UNMODIFIED => 'UNMODIFIED'
+};
+
+my $have_modified = 0;
+
+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;
+ $have_modified++;
+ }
+ } 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) {
+ push @attribs, $freshness;
+ }
+ $$change{annotation} = ' ['.join('; ', @attribs).']'
+ if (@attribs);
+ }
+}
+
sub make_listing($)
{
my ($group) = @_;
+ annotate_changes($group);
return report_pushed_changes($group);
}
@@ -392,16 +445,35 @@ sub push_changes($)
run_process(FWD_OUTPUT, @gitcmd);
}
+sub update_state($)
+{
+ my ($group) = @_;
+
+ foreach my $change (@{$$group{changes}}) {
+ my $sha1 = $$change{local}{id};
+ $$change{pushed} = $sha1;
+ }
+}
+
sub execute_pushing()
{
my $group = get_changes();
determine_remote_branch($group);
+ classify_changes_offline($group);
if ($list_only) {
show_changes($group);
print "Not pushing - list mode.\n" if ($debug);
} else {
show_changes($group) if (!$quiet);
- push_changes($group);
+ if ($have_modified) {
+ push_changes($group);
+ } else {
+ print "No modified commits - nothing to push.\n" if (!$quiet);
+ }
+
+ # This makes sense even if no modified commits are pushed
+ # (e.g., syncing state after a dumb push).
+ update_state($group);
}
}
@@ -411,3 +483,4 @@ goto_gitdir();
load_state();
determine_local_branch();
execute_pushing();
+save_state($dry_run);
diff --git a/bin/git_gpush.pm b/bin/git_gpush.pm
index 2daa737..1585ef3 100644
--- a/bin/git_gpush.pm
+++ b/bin/git_gpush.pm
@@ -7,6 +7,7 @@
package git_gpush;
+use v5.14;
use strict;
use warnings;
no warnings qw(io);
@@ -17,6 +18,7 @@ $SIG{__DIE__} = \&Carp::confess;
use List::Util qw(min max);
use File::Spec;
+use File::Temp qw(mktemp);
use IPC::Open3 qw(open3);
use Term::ReadKey;
use Text::Wrap;
@@ -195,6 +197,18 @@ sub read_process($)
return $_;
}
+# Read all lines from the process' stdout.
+sub read_process_all($)
+{
+ my ($process) = @_;
+
+ my $fh = $$process{stdout};
+ my @lines = <$fh>;
+ chomp @lines;
+ printf("Read %d lines.\n", int(@lines)) if ($debug);
+ return \@lines;
+}
+
# Read any number of null-terminated fields from the process' stdout.
sub read_fields($@)
{
@@ -276,6 +290,27 @@ sub git_config($;$)
return scalar(@cfg) ? $cfg[-1] : $dflt;
}
+our $_indexfile;
+END { unlink($_indexfile) if ($_indexfile); }
+
+sub with_local_git_index($@)
+{
+ my ($callback, @args) = @_;
+
+ $_indexfile = mktemp(($ENV{TMPDIR} or "/tmp") . "/git-gpush.XXXXXX");
+ local $ENV{GIT_INDEX_FILE} = $_indexfile;
+
+ local ($SIG{HUP}, $SIG{INT}, $SIG{QUIT}, $SIG{TERM});
+ $SIG{HUP} = $SIG{INT} = $SIG{QUIT} = $SIG{TERM} = sub { exit; };
+
+ my $ret = $callback->(@args);
+
+ unlink($_indexfile);
+ $_indexfile = undef;
+
+ return $ret;
+}
+
#################
# configuration #
#################
@@ -442,21 +477,110 @@ sub changes_from_commits($)
##################
# This is built upon Change objects with these attributes:
+# - key: Sequence number. This runs independently from Gerrit, so
+# we can enumerate Changes which were never pushed, and to make
+# it possible to re-associate local Changes with remote ones.
# - id: Gerrit Change-Id.
# - src: Local branch name, or "-" if Change is on a detached HEAD.
+# - pushed: SHA1 of the commit this Change was pushed as last time
+# from this repository.
-# Known Gerrit Changes for the current repository, indexed by Change-Id.
-# A Change can exist on multiple branches, so the values are arrays.
+my $next_key = 10000;
+# All known Gerrit Changes for the current repository.
+our %change_by_key; # { sequence-number => change-object }
+# Same, indexed by Gerrit Change-Id. A Change can exist on multiple branches.
our %changes_by_id; # { gerrit-id => [ change-object, ... ] }
+my $state_lines;
+my $state_updater = ($0 =~ s,^.*/,,r)." @ARGV";
+
+# Perform a batch update of refs.
+sub update_refs($$)
+{
+ my ($flags, $updates) = @_;
+
+ if (!@$updates) {
+ print "No refs to update.\n" if ($debug);
+ return;
+ }
+ my $pipe = open_process(USE_STDIN | FWD_STDERR | $flags, "git", "update-ref", "--stdin");
+ write_process($pipe, @$updates);
+ close_process($pipe);
+}
+
+sub _commit_state($)
+{
+ my ($blob) = @_;
+
+ run_process(0, 'git', 'update-index', '--add', '--cacheinfo', "100644,$blob,state");
+ my $tree = read_cmd_line(0, 'git', 'write-tree');
+ my $sha1 = read_cmd_line(0, 'git', 'commit-tree', '-m', 'Saving state', $tree);
+ run_process(0, 'git', 'update-ref', '-m', $state_updater,
+ '--create-reflog', 'refs/gpush/state', $sha1);
+}
+
+sub save_state(;$)
+{
+ my ($dry) = @_;
+
+ print "Saving state".($dry ? " [DRY]" : "")." ...\n" if ($debug);
+ my (@lines, @updates);
+ my @fkeys = ('key', 'id', 'src');
+ my @rkeys = ('pushed');
+ push @lines,
+ "next_key $next_key",
+ "";
+ foreach my $key (sort keys %change_by_key) {
+ my $change = $change_by_key{$key};
+ my $garbage = $$change{garbage};
+ foreach my $ky (@rkeys) {
+ my ($val, $oval) = ($garbage ? undef : $$change{$ky}, $$change{'_'.$ky});
+ if (!defined($val)) {
+ push @updates, "delete refs/gpush/i${key}_$ky\n"
+ if (defined($oval));
+ } else {
+ push @updates, "update refs/gpush/i${key}_$ky $val\n"
+ if (!defined($oval) || ($oval ne $val));
+ }
+ $$change{'_'.$ky} = $val;
+ }
+ next if ($garbage);
+ foreach my $ky (@fkeys) {
+ my $val = $$change{$ky};
+ if (defined($val)) {
+ push @lines, "$ky $val";
+ }
+ }
+ push @lines, "";
+ }
+ update_refs($dry ? DRY_RUN : 0, \@updates);
+
+ # We save the state file in a git ref as well, so the entire state
+ # can be synced between hosts with git operations.
+ if ("@lines" ne "@$state_lines") {
+ my $sts = open_process(USE_STDIN | SILENT_STDIN | USE_STDOUT | FWD_STDERR,
+ 'git', 'hash-object', '-w', '--stdin');
+ write_process($sts, map { "$_\n" } @lines);
+ my $blob = read_process($sts);
+ close_process($sts);
+ with_local_git_index(\&_commit_state, $blob) if (!$dry);
+ $state_lines = \@lines;
+ } else {
+ print "State file unmodified.\n" if ($debug);
+ }
+}
+
# Constructor for the Change object.
sub _init_change($$)
{
my ($change, $changeid) = @_;
- print "Creating Change $changeid.\n" if ($debug);
+ print "Creating Change $next_key ($changeid).\n" if ($debug);
+ $$change{key} = $next_key;
$$change{id} = $changeid;
push @{$changes_by_id{$changeid}}, $change;
+ $change_by_key{$next_key} = $change;
+ $next_key++;
}
use constant {
@@ -484,31 +608,98 @@ sub change_for_id($;$)
return undef;
}
+sub load_state_file()
+{
+ my $sts = open_process(SOFT_FAIL | USE_STDOUT | NUL_STDERR,
+ 'git', 'cat-file', '-p', 'refs/gpush/state:state');
+ $state_lines = read_process_all($sts);
+ close_process($sts);
+ return if (!@$state_lines);
+
+ my $line = 0;
+ my $inhdr = 1;
+ my $change;
+ my @changes;
+ foreach (@$state_lines) {
+ $line++;
+ if (!length($_)) {
+ $inhdr = 0;
+ $change = undef;
+ } elsif (!/^(\w+) (.*)/) {
+ fail("Bad state file: Malformed entry at line $line.\n");
+ } elsif ($inhdr) {
+ if ($1 eq "next_key") {
+ $next_key = int($2);
+ } else {
+ fail("Bad state file: Unknown header keyword '$1' at line $line.\n");
+ }
+ } else {
+ if (!$change) {
+ $change = {};
+ $$change{line} = $line;
+ push @changes, $change;
+ }
+ $$change{$1} = $2;
+ }
+ }
+
+ foreach my $change (@changes) {
+ my $line = $$change{line};
+ my ($key, $id) = ($$change{key}, $$change{id});
+ fail("Bad state file: Change with no key at line $line.\n") if (!$key);
+ fail("Bad state file: Change with no id at line $line.\n") if (!$id);
+ fail("Bad state file: Change with duplicate id at line $line.\n")
+ if (defined($change_by_key{$key}));
+ $change_by_key{$key} = $change;
+ push @{$changes_by_id{$id}}, $change;
+ }
+}
+
sub load_refs(@)
{
my (@refs) = @_;
+ my @updates;
my $info = open_cmd_pipe(0, 'git', 'for-each-ref', '--format=%(objectname) %(refname)', @refs);
while (read_process($info)) {
if (m,^(.{40}) refs/heads/(.*)$,) {
$local_refs{$2} = $1;
} elsif (m,^(.{40}) refs/remotes/([^/]+)/(.*)$,) {
$remote_refs{$2}{$3} = $1;
+ } elsif (m,^(.{40}) refs/gpush/i(\d+)_(.*)$,) {
+ my $change = $change_by_key{$2};
+ if (!$change) {
+ my $ref = substr($_, 41);
+ werr("Warning: Unrecognized Change key in state ref $ref - dropping.\n");
+ # It would cause trouble once the key is re-used.
+ push @updates, "delete $ref\n";
+ next;
+ }
+ $$change{$3} = $1;
+ $$change{'_'.$3} = $1;
}
}
close_process($info);
+ update_refs(0, \@updates);
}
sub load_state()
{
print "Loading state ...\n" if ($debug);
- load_refs("refs/heads/", "refs/remotes/");
+ load_state_file();
+ load_refs("refs/gpush/i*", "refs/heads/", "refs/remotes/");
}
##########################
# commit metadata output #
##########################
+# Don't let lists get arbitrarily wide, as this makes them hard to follow.
+use constant _LIST_WIDTH => 120;
+# The _minimal_ width assumed for annotations, even if absent. This
+# ensures that annotations always "stick out" on the right, even if only
+# short subjects have annotations.
+use constant _ANNOTATION_WIDTH => 6;
# Elide over-long Change subjects, as they add nothing but noise.
use constant _SUBJECT_WIDTH => 70;
# Truncation width for Change-Ids and SHA1s; empirically determined to be
@@ -540,8 +731,8 @@ sub _unpack_report($@)
{
my $report = shift @_;
my @a = ($$report{id}, $$report{subject},
- $$report{prefix} // "");
- push @a, length($a[2]);
+ $$report{prefix} // "", $$report{suffix} // "", $$report{annotation} // "");
+ push @a, length($a[2]) + length($a[3]) + max(length($a[4]), _ANNOTATION_WIDTH);
for (@_) { $_ = shift @a; }
}
@@ -549,15 +740,26 @@ sub format_reports($)
{
my ($reports) = @_;
+ my $width = 0;
+ foreach my $report (@$reports) {
+ next if ($$report{type} ne "change");
+ _unpack_report($report, my ($id, $subject, $pfx_, $sfx_, $ann_, $fixlen));
+ my $w = length($subject);
+ $w += _ID_WIDTH + 3 if (defined($id));
+ $width = max($width, min($w, _SUBJECT_WIDTH) + $fixlen);
+ }
+ $width = min($width, $tty_width, _LIST_WIDTH);
my $output = "";
foreach my $report (@$reports) {
my $type = $$report{type} // "";
if ($type eq "flowed") {
$output .= wrap("", "", $_)."\n" foreach (@{$$report{texts}});
} elsif ($type eq "change") {
- _unpack_report($report, my ($id, $subject, $prefix, $fixlen));
- my $str = format_subject($id, $subject, -$fixlen);
- $output .= $prefix.$str."\n";
+ _unpack_report($report, my ($id, $subject, $prefix, $suffix, $annot, $fixlen));
+ my $w = $width - $fixlen;
+ my $str = format_subject($id, $subject, min($w, _SUBJECT_WIDTH));
+ my $spacing = length($annot) ? (" " x max($w - length($str), 0)) : "";
+ $output .= $prefix.$str.$suffix.$spacing.$annot."\n";
} else {
die("Unknown report type '$type'.\n");
}
@@ -586,6 +788,7 @@ sub report_local_changes($$)
id => $$commit{changeid},
subject => $$commit{subject},
prefix => " ",
+ annotation => $$change{annotation}
};
}
}