summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
authorOswald Buddenhagen <oswald.buddenhagen@qt.io>2017-08-03 16:27:42 +0200
committerOswald Buddenhagen <oswald.buddenhagen@gmx.de>2020-04-01 18:39:24 +0000
commit03c8458dd00a594a6087ceaa16f3c297de6f200a (patch)
tree0f167c1635202ea11c972a0a88049b43034e33de /bin
parentb215b694ee349918c0357f477fe969361b8cbd01 (diff)
downloadqtrepotools-03c8458dd00a594a6087ceaa16f3c297de6f200a.tar.gz
gpush/gpick: implement local branch tracking
this helps dealing with cherry-picks between local branches. Change-Id: Ie3f7567639cc05e03257cd5bac133714e5c4f068 Reviewed-by: Oswald Buddenhagen <oswald.buddenhagen@gmx.de>
Diffstat (limited to 'bin')
-rwxr-xr-xbin/git-gpick50
-rwxr-xr-xbin/git-gpush77
-rw-r--r--bin/git_gpush.pm539
3 files changed, 623 insertions, 43 deletions
diff --git a/bin/git-gpick b/bin/git-gpick
index 91f213f..cc9cd78 100755
--- a/bin/git-gpick
+++ b/bin/git-gpick
@@ -146,6 +146,14 @@ Options:
to Gerrit. In case of ambiguity, the default is the upstram branch
of the current local branch.
+ --move/--copy/--hide <args>
+ Deal with cherry-picks of Changes between local branches.
+ This works the same as documented for git-gpush, except that the
+ range may also be a plus-sign prefixed addition specification.
+ Unlike local ranges, these DO get picked without being specified
+ redundantly, because otherwise there would be nothing to operate
+ on in the first place.
+
-n, --dry-run
Do everything except actually replacing any commits and updating
state.
@@ -287,6 +295,8 @@ sub parse_arguments(@)
} elsif ($arg eq "-?" || $arg eq "--?" || $arg eq "-h" || $arg eq "--help") {
usage();
exit 0;
+ } elsif (parse_source_option($arg, 1, @_)) {
+ # Nothing
} elsif ($arg !~ /^-/) {
push @commit_specs, $arg;
} else {
@@ -1549,17 +1559,20 @@ sub resolve_insertion_spec($$)
$$spec{changes} = \%result;
}
-sub changify_remote_series($)
+sub changify_remote_series($$)
{
- my ($series) = @_;
+ my ($series, $reference) = @_;
+ my $fails;
my @result;
foreach my $commit (@$series) {
- my $change = change_for_id($$commit{changeid}, CREATE);
+ my $change = source_map_assign($commit, $reference);
+ $fails = 1 if (!$change);
+ next if ($fails);
$$change{gerrit} = $gerrit_info_by_sha1{$$commit{id}};
push @result, $change;
}
- return \@result;
+ return $fails ? undef : \@result;
}
sub do_sort_series($$$$$);
@@ -1740,7 +1753,7 @@ sub finalize_remote_series($$$)
my $sorted = sort_series($name, $range, $commits, 1);
fail("Series $name is empty after filtering.\n") if (!@$sorted && !$check);
- return changify_remote_series($sorted);
+ return $sorted;
}
# Download the metadata and PatchSets referenced by the specs.
@@ -1894,6 +1907,25 @@ sub complete_spec_tails($)
}
}
+sub changify_remote_specs($)
+{
+ my ($specs) = @_;
+
+ while (1) {
+ foreach my $spec (@$specs) {
+ my $commits = $$spec{new_range_commits};
+ next if (!$commits);
+ my $changes = changify_remote_series($commits, $$spec{new_range_reference});
+ if ($changes) {
+ $$spec{new_range} = $changes;
+ delete $$spec{new_range_commits};
+ }
+ }
+ last if (!source_map_traverse());
+ }
+ source_map_finish();
+}
+
sub check_specs($)
{
my ($specs) = @_;
@@ -1905,8 +1937,10 @@ sub check_specs($)
# the step may be obsoleted by the spec being matched up.
next if ($$spec{action} != INSERT);
- $$spec{new_range} = finalize_remote_series($$spec{orig}, [], $$spec{changes});
+ $$spec{new_range_commits} = finalize_remote_series($$spec{orig}, [], $$spec{changes});
+ $$spec{new_range_reference} = $$spec{tip};
}
+ changify_remote_specs($specs);
print "Checking remote commit specs for collisions ...\n" if ($debug);
my @new_specs;
@@ -2065,9 +2099,11 @@ sub finalize_specs($)
next if ($$spec{action} != UPDATE);
next if ($$spec{new_range});
- $$spec{new_range} =
+ $$spec{new_range_commits} =
finalize_remote_series("$$spec{orig}/remote", $$spec{range}, $$spec{changes});
+ $$spec{new_range_reference} = $$spec{range}[-1];
}
+ changify_remote_specs($specs);
return if ($force_struct);
diff --git a/bin/git-gpush b/bin/git-gpush
index f84d520..2215b02 100755
--- a/bin/git-gpush
+++ b/bin/git-gpush
@@ -144,6 +144,42 @@ Options:
Reset the base of a previously pushed series to the local branch's
base. Typically used after a conflicted pull.
+ --move {new|<range>}[/<from>]
+ After cherry-picking Changes to the current branch, mark the picks
+ as the _only_ source for subsequent pushes of these Changes.
+ The Changes on the current branch inherit all persistent properties,
+ while those on the source branch are hidden, in case they were not
+ dropped to start with.
+ <from> must be supplied only if the source branch is ambiguous.
+ This option is necessary only when a move cannot be unambiguously
+ inferred.
+
+ --copy {new|<range>}
+ After cherry-picking Changes to the current branch, mark the picks
+ as an _additional_ source for subsequent pushes of these Changes.
+ The Changes on the source branch are left alone, while those on the
+ current branch start out with a clean slate (which implies a
+ different default target branch).
+ When a range is specified, the Changes are also --group'd.
+
+ --hide {new|<range>}
+ After cherry-picking Changes to the current branch, mark the picks
+ as an _unacceptable_ source for subsequent pushes of these Changes.
+ When a range is specified, the Changes are also --group'd.
+
+ The range can be specified as <base>..<tip> (either end can be left
+ off) or <tip>:<count>. When using 'new' instead of a range, all
+ Changes which were previously not seen on the current branch are
+ selected, while actual ranges select Changes regardless of whether
+ they were already seen, which makes it possible to revise previous
+ decisions about the authoritative source for pushes.
+ --move, --copy, and --hide can be specified multiple times. The
+ ranges may not overlap, but will override a single also specified
+ operation which uses 'new'.
+ Note that the ranges supplied to these options do NOT imply these
+ Changes getting pushed; to do so, specify them a second time as a
+ regular <from> argument.
+
-l, --list
Report all Changes that would be pushed, then quit.
This is a purely off-line operation.
@@ -342,6 +378,8 @@ sub parse_arguments(@)
} elsif ($arg eq "-?" || $arg eq "--?" || $arg eq "-h" || $arg eq "--help") {
usage();
exit 0;
+ } elsif (parse_source_option($arg, 0, @_)) {
+ # Nothing
} elsif ($arg =~ /^\+(.+)/) {
push @reviewers, split(/,/, lookup_alias($1));
} elsif ($arg =~ /^\=(.+)/) {
@@ -498,11 +536,15 @@ sub caption_group($)
my $tpc = $$group{topic};
my $tpcs = length($tpc) ? ", topic '$tpc'" : "";
my ($pfx, $rmt) = $list_only
- ? ($$group{exclude_mix}
- ? "Partially excluded series of"
- : $$group{exclude}
- ? "Excluded series of"
- : "Series of",
+ ? ($$group{hide_mix}
+ ? "Partially hidden series of"
+ : $$group{hide}
+ ? "Hidden series of"
+ : $$group{exclude_mix}
+ ? "Partially excluded series of"
+ : $$group{exclude}
+ ? "Excluded series of"
+ : "Series of",
($list_online && length($tos)) ? " on '$remote'" : "")
: $group_only
? ("Grouping", "")
@@ -780,6 +822,8 @@ sub finalize_get_changes($$)
my ($changes, $gid) = @_;
my %group = (changes => $changes, gid => $gid);
+ aggregate_bool_property(\%group, "hide", "HIDDEN", "hiding",
+ "Use --move/--copy/--hide to make it consistent.");
return \%group;
}
@@ -842,6 +886,20 @@ sub get_changes()
}
fail("Specified commit range is empty.\n") if (!@$changes);
my $group = finalize_get_changes($changes, $gid);
+ # Note that grouping/excluding hidden series is also rejected,
+ # as there is just no point to it - --copy and --hide imply
+ # regrouping anyway, and --move uses the grouping of the source,
+ # so any grouping done while the Changes are hidden will be lost.
+ if ($$group{hide}) {
+ my @reports;
+ report_flowed(\@reports,
+ "Refusing to operate hidden series of ".int(@$changes)." Change(s):");
+ report_local_changes(\@reports, $changes);
+ report_fixed(\@reports,
+ "Operate it from the previously chosen source branch,\n",
+ "or use --move/--copy to unhide it.\n");
+ fail_formatted(\@reports);
+ }
return $group;
}
@@ -858,7 +916,8 @@ sub get_all_changes()
my $group = finalize_get_changes($changes, $gid);
aggregate_bool_property($group, "exclude", "EXCLUDED", "exclusion",
"Please gpush it separately or use --group/--exclude"
- ." to make it consistent.");
+ ." to make it consistent.")
+ if (!$$group{hide});
unshift @groups, $group;
$have_loose++ if (!$gid);
last if (!$change);
@@ -1580,9 +1639,11 @@ sub annotate_changes($)
{
my ($group) = @_;
- my $excl_mix = $list_only && $$group{exclude_mix};
+ my $hide_mix = $list_only && $$group{hide_mix};
+ my $excl_mix = !$hide_mix && $list_only && $$group{exclude_mix};
foreach my $change (@{$$group{changes}}) {
my @attribs;
+ push @attribs, 'HIDDEN' if ($hide_mix && $$change{hide});
push @attribs, 'EXCLUDED' if ($excl_mix && $$change{exclude});
# Changes can be loose only when pushing all or listing, as
# otherwise they are currently being assigned, and showing
@@ -1897,7 +1958,7 @@ sub execute_pushing()
}
fail_push($all_groups, "Cannot push all with any free-standing loose Changes.\n");
}
- $groups = [ grep { !$$_{exclude} } @$all_groups ];
+ $groups = [ grep { !$$_{hide} && !$$_{exclude} } @$all_groups ];
} else {
$groups = $all_groups = [ get_changes() ];
}
diff --git a/bin/git_gpush.pm b/bin/git_gpush.pm
index 252925a..ce1a106 100644
--- a/bin/git_gpush.pm
+++ b/bin/git_gpush.pm
@@ -653,6 +653,8 @@ our %gerrit_infos_by_id;
# attributes, used by --group mode.
# - exclude: Flag indicating whether the Change is excluded from
# push --all mode.
+# - hide: Flag indicating whether the Change is excluded from all
+# pushes.
my $next_key = 10000;
# All known Gerrit Changes for the current repository.
@@ -703,7 +705,7 @@ sub save_state(;$$)
print "Saving ".($new ? "new " : "")."state".($dry ? " [DRY]" : "")." ...\n" if ($debug);
my (@lines, @updates);
my @fkeys = ('key', 'grp', 'id', 'src', 'tgt', 'topic', 'base',
- 'ntgt', 'ntopic', 'nbase', 'exclude');
+ 'ntgt', 'ntopic', 'nbase', 'exclude', 'hide');
my @rkeys = ('pushed', 'orig');
if ($new) {
push @lines, "verify $new", "updater $state_updater";
@@ -787,31 +789,6 @@ sub _init_change($$)
$next_key++;
}
-use constant {
- CREATE => 1
-};
-
-# Get a Change object for a given Id on the _local_ branch. If no such
-# object exists, create a new one if requested, otherwise return undef.
-sub change_for_id($;$)
-{
- my ($changeid, $create) = @_;
-
- my $br = $local_branch // "-";
- my $chgs = $changes_by_id{$changeid};
- if ($chgs) {
- foreach my $chg (@$chgs) {
- return $chg if ($$chg{src} eq $br);
- }
- }
- if ($create) {
- my %chg = (src => $br);
- _init_change(\%chg, $changeid);
- return \%chg;
- }
- return undef;
-}
-
sub load_state_file(;$)
{
my ($new) = @_;
@@ -1187,6 +1164,11 @@ our $local_tip;
# Mapping of Change-Ids to commits on the local branch.
my %changeid2local; # { change-id => SHA1 }
+sub _source_map_prepare();
+sub source_map_assign($$);
+sub source_map_traverse();
+sub _source_map_finish_initial();
+
sub analyze_local_branch($)
{
my ($tip) = @_;
@@ -1222,14 +1204,25 @@ sub analyze_local_branch($)
$local_base = $$commits[0]{parents}[0];
+ # This needs to happen early, for parse_local_rev(), which
+ # _source_map_prepare() calls.
$changeid2local{$$_{changeid}} = $$_{id} foreach (@$commits);
+ # ... then map them to Change objects ...
+ _source_map_prepare();
+ while (1) {
+ foreach my $commit (@$commits) {
+ source_map_assign($commit, undef);
+ }
+ last if (!source_map_traverse());
+ }
+ _source_map_finish_initial();
+
# ... and then add them to the set of local Changes.
my $idx = 0;
my $prev;
foreach my $commit (@$commits) {
- my $change = change_for_id($$commit{changeid}, CREATE);
- $$commit{change} = $change;
+ my $change = $$commit{change};
$$change{local} = $commit;
$$change{index} = $idx++;
$$change{parent} = $prev;
@@ -1552,6 +1545,496 @@ sub create_commit($$$$$)
# branch tracking #
###################
+# This tracks the local branch each Change lives on, following cherry-picks
+# and purges as much as possible.
+
+# Format a single branch name for display.
+sub _format_branch($)
+{
+ my ($branch) = @_;
+
+ return ($branch ne '-') ? "'$branch'" : '<detached HEAD>';
+}
+
+# Format the source branch names from an array of Changes,
+# using Oxford English grammar.
+sub _format_branches_raw($)
+{
+ my ($changes) = @_;
+
+ my @branches = sort map { _format_branch($$_{src}) } @$changes;
+ my $str = "$branches[0]";
+ if (@branches > 1) {
+ if (@branches > 2) {
+ $str .= ", $branches[$_]" for (1 .. @branches - 2);
+ $str .= ",";
+ }
+ $str .= " and $branches[-1]";
+ }
+ return $str;
+}
+
+# As above, but with quantifiers to signify that we mean all branches.
+sub _format_branches(@)
+{
+ my ($changes) = @_;
+
+ my $str = _format_branches_raw($changes);
+ return $str if (@$changes == 1);
+ return "both $str" if (@$changes == 2);
+ return "all of $str";
+}
+
+# Format the message about the outcome of a Change assignment attempt.
+sub _format_result($$$@)
+{
+ my ($commit, $new, $fmt, @args) = @_;
+
+ my $lbr = $local_branch // "-";
+ return sprintf("%s\n %son %s $fmt.\n", format_commit($commit),
+ $new ? "newly " : "", _format_branch($lbr), @args);
+}
+
+use constant {
+ _SRC_NOOP => 0,
+ _SRC_MOVE => 1,
+ _SRC_COPY => 2,
+ _SRC_HIDE => 3
+};
+
+my @sm_options;
+my $sm_option_new;
+my %sm_option_by_id;
+my %sm_wanted;
+my %sm_present;
+my $sm_printed = 0;
+my $sm_changed = 0;
+my $sm_failed = 0;
+
+# Parse a single command line option relating to source branch tracking.
+# $arg is the currently processed argument, while $args are the remaining
+# arguments on the command line. If $rmt_ok is true, plus-prefixed remote
+# specifications are accepted as well; note that these are not removed
+# from the command line, unlike local ones.
+sub parse_source_option($$\@)
+{
+ my ($arg, $rmt_ok, $args) = @_;
+
+ my $action;
+ if ($arg eq "--move") {
+ $action = _SRC_MOVE;
+ } elsif ($arg eq "--copy") {
+ $action = _SRC_COPY;
+ } elsif ($arg eq "--hide") {
+ $action = _SRC_HIDE;
+ } else {
+ return undef;
+ }
+ fail("$arg needs an argument.\n")
+ if (!@$args || ($$args[0] =~ /^-/));
+ my $orig = shift @$args;
+ my $tip = $orig;
+ my ($base, $count);
+ my $branch = $1 if ($tip =~ s,/(.*)$,,);
+ my $rmt_id;
+ if ($rmt_ok && $tip =~ /^\+(\w+)/) {
+ $rmt_id = $1;
+ unshift @$args, $tip;
+ } else {
+ $base = $1 if ($tip =~ s,^(.*)\.\.,,);
+ $count = $1 if ($tip =~ s,:(.*)$,,);
+ $tip = undef if ($tip eq "new");
+ fail("Specifying a commit count and a range base are mutually exclusive.\n")
+ if (defined($base) && defined($count));
+ if (defined($base) || defined($count)) {
+ wfail("Specifying a commit count or range base is incompatible with range 'new'.\n")
+ if (!defined($tip));
+ } else {
+ wfail("Automatic ranges are not supported with $arg."
+ ." Use $tip:1 if you actually meant a single Change.\n")
+ if (defined($tip));
+ }
+ }
+ fail("$arg does not support specifying a source branch.\n")
+ if (defined($branch) && ($action != _SRC_MOVE));
+ push @sm_options, {
+ action => $action,
+ orig => $orig,
+ rmt_id => $rmt_id,
+ base => defined($base) ? length($base) ? $base : '@{u}' : undef,
+ tip => defined($tip) ? length($tip) ? $tip : 'HEAD' : undef,
+ count => $count,
+ branch => $branch
+ };
+ return 1;
+}
+
+# Do final sanity checking on the source branch tracking related commands,
+# expand the supplied ranges into series of commits, and create a reverse
+# mapping of commits to command objects.
+sub _source_map_prepare()
+{
+ my $br = $local_branch // "-";
+ foreach my $option (@sm_options) {
+ my $sbr = $$option{branch};
+ wfail("Source and target branch are both '$br' in attempt to move Changes.\n")
+ if (defined($sbr) && ($sbr eq $br));
+
+ my $rmt_id = $$option{rmt_id};
+ if (defined($rmt_id)) {
+ $sm_option_by_id{$rmt_id} = $option;
+ next;
+ }
+
+ my $raw_tip = $$option{tip};
+ if (defined($raw_tip)) {
+ my $commits;
+ my $tip = parse_local_rev($raw_tip, SPEC_TIP);
+ my $raw_base = $$option{base};
+ if (defined($raw_base)) {
+ my $base = parse_local_rev($raw_base, SPEC_BASE);
+ $commits = get_commits_base($base, $tip, $raw_base, $raw_tip);
+ } else {
+ my $count = $$option{count};
+ $commits = get_commits_count($tip, $count, $raw_tip);
+ }
+ foreach my $commit (@$commits) {
+ my $sha1 = $$commit{id};
+ my $old_option = $sm_option_by_id{$sha1};
+ wfail("Range $$option{orig} intersects $$old_option{orig} (at $sha1).\n")
+ if ($old_option);
+ $sm_option_by_id{$sha1} = $option;
+ }
+ $$option{commits} = $commits;
+ next;
+ }
+
+ wfail("Only one of --move, --copy, and --hide may be specified with 'new'.\n")
+ if (defined($sm_option_new));
+ $sm_option_new = $option;
+ }
+}
+
+# Schedule the removal a single Change object from the state database.
+sub _obliterate_change($$)
+{
+ my ($change, $changes) = @_;
+
+ print "Obliterating $$change{key} ($$change{id}) from $$change{src}.\n" if ($debug);
+ @$changes = grep { $_ != $change } @$changes; # From %changes_by_id
+ $$change{garbage} = 1; # Mark it for %change_by_key traversal
+}
+
+# Visit the requested non-current local branches with the purpose of
+# determining which Change objects still correspond with actual commits.
+sub source_map_traverse()
+{
+ # It would be tempting to always query all local branches in
+ # advance, to save the extra git call. However, this would also
+ # collect *really* old branches, and even though they are short,
+ # calculating the exclusion takes hundreds of milliseconds,
+ # which is slower than an extra git call even on Windows.
+ # Additionally, we need to traverse down to the push base of the
+ # previously pushed Changes (in case they were upstreamed already),
+ # and this can also have a significant cost if the corresponding
+ # local branch advanced a lot since a Change was pushed last time,
+ # so it's better to do that selectively.
+
+ return if (!%sm_wanted);
+
+ # This loop is likely to run only once per call, so don't bother
+ # coalescing the resulting visit_*() calls.
+ foreach my $br (keys %sm_wanted) {
+ print "Investigating other local branch $br ...\n" if ($debug);
+ my %present;
+ my @changes = grep { $$_{src} eq $br } values %change_by_key;
+ my (@missing, $utips);
+ my $tip = $local_refs{$br};
+ if (defined($tip)) {
+ visit_local_commits([ $tip ]);
+ my $commits = get_commits_free($tip);
+ print "Still present on the branch:\n".format_commits($commits) if ($debug);
+ if (@$commits) {
+ %present = map { $$_{changeid} => 1 } @$commits;
+
+ # Prepare garbage collection.
+ @missing = grep { !defined($present{$$_{id}}) } @changes;
+ $utips = [ get_1st_parent($$commits[0]) ];
+ } else {
+ @missing = @changes;
+ $utips = [ $tip ];
+ }
+ } else {
+ print "Branch disappeared.\n" if ($debug);
+ # The branch was deleted, so all its Changes are missing.
+ @missing = @changes;
+ # For the upstream check, we fall back to the branches the Changes
+ # were targeting. This is worse than using the actual upstream, as
+ # the target may be outdated and the Change actually went to another
+ # branch. Also, it's potentially more work.
+ my $urefs = $remote_refs{$upstream_remote};
+ if ($urefs) {
+ my %utiph = map { $_ => 1 } grep { $_ } map { $$urefs{$$_{tgt}} } @changes;
+ $utips = [ keys %utiph ];
+ }
+ }
+ $sm_present{$br} = \%present;
+
+ # We may need to garbage-collect the branch.
+ # Failure to do this would affect cherry-picks of Changes that were
+ # upstreamed - neither do we want to need --copy for Changes that are
+ # actually gone, nor do we want them to be detected as moves (and thus
+ # inherit the now incorrect target branch).
+ if (@missing) {
+ # Record the push base of each Change - if a Change is actually
+ # in our upstream, it will be so between its push base (which
+ # cannot be younger than the branch's tip at that time) and the
+ # branch's merge base with upstream.
+ my %bases = map { $_ => 1 } grep { $_ } map { $$_{base} } @missing;
+ if (%bases) {
+ print "Visiting upstream of other local branch $br ...\n" if ($debug);
+ my $urevs = visit_upstream_commits($utips, [ keys %bases ]);
+ foreach my $change (@missing) {
+ my $changeid = $$change{id};
+ _obliterate_change($change, $changes_by_id{$changeid})
+ if (defined($$urevs{$changeid}));
+ }
+ }
+ }
+ }
+ %sm_wanted = ();
+ return 1;
+}
+
+# Determine which of the Change objects in $changes still refer to
+# actual commits.
+sub _find_candidate_sources($$$)
+{
+ my ($changes, $vanished, $persisting) = @_;
+
+ return 0 if (!$changes);
+
+ my $lbr = $local_branch // "-";
+ my $retry = 0;
+ foreach my $chg (@$changes) {
+ # Hidden Changes are supposed to be skipped over, so it
+ # would be backwards to use them as sources for moves.
+ next if ($$chg{hide});
+
+ my $obr = $$chg{src};
+ # The current branch is obviously not a sensible source.
+ next if ($obr eq $lbr);
+
+ # Note that detached HEADs "vanish" after switching to an actual
+ # branch, thereby freeing all Changes assigned to them, sans the
+ # garbage-collected ones.
+ my $present = $sm_present{$obr};
+ if (!$present) {
+ # Ensure that the branch is visited and garbage-collected.
+ $sm_wanted{$obr} = 1;
+ $retry = 1;
+ } elsif (defined($$present{$$chg{id}})) {
+ print "$$chg{id} persists on $obr.\n" if ($debug);
+ push @$persisting, $chg;
+ } else {
+ print "$$chg{id} vanished from $obr.\n" if ($debug);
+ $$chg{vanished} = 1;
+ push @$vanished, $chg;
+ }
+ }
+ return $retry;
+}
+
+sub source_map_assign($$)
+{
+ my ($commit, $reference) = @_;
+
+ my $change = $$commit{change};
+ return $change if ($change);
+
+ my $lbr = $local_branch // "-";
+
+ my $changeid = $$commit{changeid};
+ my $changes = $changes_by_id{$changeid};
+ my %change_by_branch;
+ if ($changes) {
+ foreach my $chg (@$changes) {
+ my $br = $$chg{src};
+ $change_by_branch{$br} = $chg;
+ }
+ }
+ $change = $change_by_branch{$lbr};
+ my $new = !$change;
+
+ my $option = $sm_option_by_id{$reference // $$commit{id}} // $sm_option_new;
+ my $new_only = $option && !defined($$option{tip});
+ my $action = $option ? $$option{action} : _SRC_NOOP;
+ if ($action == _SRC_COPY || $action == _SRC_HIDE) {
+ # Note: We don't bother finding Changes which could be recycled -
+ # gpull will clean up.
+ # We don't complain about no-ops, as they don't hurt.
+ my $label = ($action == _SRC_HIDE) ? "hidden" : "copied";
+ if ($new || !$new_only) {
+ if ($new) {
+ $change = {};
+ _init_change($change, $changeid);
+ }
+ $$change{hide} = ($action == _SRC_HIDE) ? 1 : undef;
+ wout(_format_result($commit, $new, "marked as $label"))
+ if (!$quiet);
+ goto PRINTED;
+ }
+ print "Already have $$change{key} ($changeid) on $lbr; not $label.\n"
+ if ($debug);
+ goto FOUND;
+ }
+ if ($new || ($action == _SRC_MOVE && !$new_only)) {
+ my $schange;
+ my $sbr = ($action == _SRC_MOVE) ? $$option{branch} : undef;
+ if (defined($sbr)) {
+ $schange = $change_by_branch{$sbr};
+ if (!$schange) {
+ werr("Change $changeid does not exist on '$sbr'; cannot move.\n");
+ goto FAIL;
+ }
+ if ($$schange{hide}) {
+ werr("Change $changeid is hidden on '$sbr'; cannot move.\n");
+ goto FAIL;
+ }
+ } else {
+ my (@vanished, @persisting);
+ if (_find_candidate_sources($changes, \@vanished, \@persisting)) {
+ print "Need to come back for $changeid.\n" if ($debug);
+ return undef;
+ }
+ if ($new) {
+ if (@vanished) {
+ if (@vanished > 1) {
+ werr(_format_result(
+ $commit, 1, "was previously on %s.\n"
+ ." Use --move with a source, or use --copy/--hide",
+ _format_branches(\@vanished)));
+ goto FAIL;
+ }
+ $change = pop @vanished;
+ # Note: this is a post-fact notice, so if the user failed to use
+ # --copy/--hide in advance, they'll need to fix it laboriously.
+ # This seems acceptable, as moving is the much more common case.
+ wout(_format_result(
+ $commit, 1, "was previously on %s. Inferring move",
+ _format_branch($$change{src})))
+ if (!$quiet);
+ goto PRINTED;
+ }
+ if (!@persisting) {
+ # This is the common case: an entirely new Change.
+ $change = {};
+ _init_change($change, $changeid);
+ goto CHANGED;
+ }
+ if ($action != _SRC_MOVE) {
+ werr(_format_result(
+ $commit, 1, "exists also on %s.\n"
+ ." Prune unneeded copies, or use --move/--copy/--hide",
+ _format_branches_raw(\@persisting)));
+ goto FAIL;
+ }
+ } else { # implies $action == _SRC_MOVE
+ if (!$$change{hide}) {
+ # Attempts to move over active Changes are most likely mistakes.
+ werr(_format_result(
+ $commit, 0, "is not hidden.\n"
+ ." Pass a source to --move if you really mean it"));
+ goto FAIL;
+ }
+ if (@vanished) {
+ if (@vanished > 1) {
+ werr(_format_result(
+ $commit, 0, "was previously on %s.\n"
+ ." Pass a source to --move to disambiguate",
+ _format_branches(\@vanished)));
+ goto FAIL;
+ }
+ $schange = pop @vanished;
+ } elsif (!@persisting) {
+ werr(_format_result(
+ $commit, 0, "exists on no other branch. Use --copy to unhide it"));
+ goto FAIL;
+ }
+ }
+ if (!$schange) {
+ if (@persisting > 1) {
+ werr(_format_result(
+ $commit, $new, "exists also on %s.\n"
+ ." Prune unneeded copies, pass a source to --move,"
+ ." or use --copy/--hide",
+ _format_branches_raw(\@persisting)));
+ goto FAIL;
+ }
+ $schange = pop @persisting;
+ }
+ $sbr = $$schange{src};
+ } # !defined($sbr)
+ if ($change) {
+ # If the slot on the current branch is busy, we need to free it first.
+ # Note that this might be a hidden entry from a previous --move/--hide.
+ _obliterate_change($change, $changes);
+ }
+ if (!$$schange{vanished}) {
+ # If the Change still does or might exist on the source branch, we
+ # need to create a hidden entry for it, so it does not appear to be
+ # new next time around.
+ my %chg = (src => $sbr, grp => $$schange{grp}, hide => 1);
+ _init_change(\%chg, $changeid);
+ print "... and hidden on $sbr.\n" if ($debug);
+ }
+ wout(_format_result($commit, $new, "moved from %s", _format_branch($sbr)))
+ if (!$quiet);
+ $change = $schange;
+ goto PRINTED;
+ }
+ print "Already have $$change{key} ($changeid) on $lbr; leaving alone.\n"
+ if ($debug);
+ goto FOUND;
+
+ PRINTED:
+ $sm_printed = 1 if (!$quiet);
+ CHANGED:
+ $$change{src} = $lbr;
+ $sm_changed = 1;
+ FOUND:
+ $$commit{change} = $change;
+ return $change;
+
+ FAIL:
+ $sm_failed = 1;
+ return undef;
+}
+
+sub source_map_finish()
+{
+ exit(1) if ($sm_failed);
+ print "\n" if ($sm_printed); # Delimit from remaining output.
+ $sm_printed = 0;
+}
+
+sub _source_map_finish_initial()
+{
+ foreach my $option (@sm_options) {
+ # Only --copy & --hide imply grouping.
+ my $action = $$option{action};
+ next if ($action != _SRC_COPY && $action != _SRC_HIDE);
+
+ my $commits = $$option{commits};
+ next if (!$commits);
+ assign_series(changes_from_commits($commits));
+ $sm_changed = 1;
+ }
+
+ source_map_finish();
+ save_state() if ($sm_changed);
+}
+
# Update the target branches of local Changes according to the data
# from a Gerrit query.
# Each SHA1 is assigned to exactly one PatchSet, which in turn is