#!/usr/bin/perl -w
# git-debrebase
# Script helping make fast-forwarding histories while still rebasing
# upstream deltas when working on Debian packaging
#
# Copyright (C)2017,2018 Ian Jackson
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# usages:
#
# git-debrebase [] new-upstream-v0 \
# \
# [ ...] \
# [...]
#
# git-debrebase [ --] []
# git-debrebase [] analyse
# git-debrebase [] breakwater # prints breakwater tip only
# git-debrebase [] stitch [--prose=]
# git-debrebase [] launder-v0 # prints breakwater tip etc.
# git-debrebase [] downstream-rebase-launder-v0 # experimental
#
# git-debrebase [] convert-from-gbp []
# git-debrebase [] convert-to-gbp
# problems / outstanding questions:
#
# * dgit push with a `3.0 (quilt)' package means doing quilt
# fixup. Usually this involves recommitting the whole patch
# series, one at a time, with dpkg-source --commit. This is
# terribly terribly slow. (Maybe this should be fixed in dgit.)
#
# * dgit push usually needs to (re)make a pseudomerge. The "first"
# git-debrebase stripped out the previous pseudomerge and could
# have remembeed the HEAD. But it's not quite clear what history
# ought to be preserved and what should be discarded. For now
# the user will have to tell dgit --overwrite.
#
# To fix this, do we need a new push hook for dgit ?
#
# * Workflow is currently clumsy. Lots of spurious runes to type.
# There's not even a guide.
#
# * There are no tests.
#
# * new-upstream-v0 has a terrible UI. You end up with giant
# runic command lines.
#
# One consequence of the lack of richness it can need --force in
# fairly sensible situations and there is no way to tell it what
# you are really trying to do, other than just --force. There
# should be an interface with some default branch names.
#
# * There should be a standard convention for the version number,
# and unfinalised or not changelog, after new-upstream.
#
# * Handing of multi-orig dgit new-upstream .dsc imports is known to
# be broken. They may be not recognised, improperly converted, or
# their conversion may be unrecognised.
#
# * Docs need writing and updating. Even README.git-debrebase
# describes a design but may not reflect the implementation.
#
# * We need to develop a plausible model that works for derivatives,
# who probably want to maintain their stack on top of Debian's.
# downstream-rebase-launder-v0 may be a starting point?
use strict;
use Debian::Dgit qw(:DEFAULT :playground);
setup_sigwarn();
use Memoize;
use Carp;
use POSIX;
use Data::Dumper;
use Getopt::Long qw(:config posix_default gnu_compat bundling);
use Dpkg::Version;
use File::FnMatch qw(:fnmatch);
our ($opt_force, $opt_noop_ok);
our $us = qw(git-debrebase);
sub badusage ($) {
my ($m) = @_;
die "bad usage: $m\n";
}
sub cfg ($;$) {
my ($k, $optional) = @_;
local $/ = "\0";
my @cmd = qw(git config -z);
push @cmd, qw(--get-all) if wantarray;
push @cmd, $k;
my $out = cmdoutput_errok @cmd;
if (!defined $out) {
fail "missing required git config $k" unless $optional;
return ();
}
my @l = split /\0/, $out;
return wantarray ? @l : $l[0];
}
memoize('cfg');
sub dd ($) {
my ($v) = @_;
my $dd = new Data::Dumper [ $v ];
Terse $dd 1; Indent $dd 0; Useqq $dd 1;
return Dump $dd;
}
sub get_commit ($) {
my ($objid) = @_;
my $data = (git_cat_file $objid, 'commit');
$data =~ m/(?<=\n)\n/ or die "$objid ($data) ?";
return ($`,$');
}
sub D_UPS () { 0x02; } # upstream files
sub D_PAT_ADD () { 0x04; } # debian/patches/ extra patches at end
sub D_PAT_OTH () { 0x08; } # debian/patches other changes
sub D_DEB_CLOG () { 0x10; } # debian/ (not patches/ or changelog)
sub D_DEB_OTH () { 0x20; } # debian/changelog
sub DS_DEB () { D_DEB_CLOG | D_DEB_OTH; } # debian/ (not patches/)
our $playprefix = 'debrebase';
our $rd;
our $workarea;
our @git = qw(git);
sub in_workarea ($) {
my ($sub) = @_;
changedir $workarea;
my $r = eval { $sub->(); };
{ local $@; changedir $maindir; }
die $@ if $@;
}
sub fresh_workarea () {
$workarea = fresh_playground "$playprefix/work";
in_workarea sub { playtree_setup };
}
sub get_differs ($$) {
my ($x,$y) = @_;
# This resembles quiltify_trees_differ, in dgit, a bit.
# But we don't care about modes, or dpkg-source-unrepresentable
# changes, and we don't need the plethora of different modes.
# Conversely we need to distinguish different kinds of changes to
# debian/ and debian/patches/.
my $differs = 0;
my $rundiff = sub {
my ($opts, $limits, $fn) = @_;
my @cmd = (@git, qw(diff-tree -z --no-renames));
push @cmd, @$opts;
push @cmd, "$_:" foreach $x, $y;
push @cmd, '--', @$limits;
my $diffs = cmdoutput @cmd;
foreach (split /\0/, $diffs) { $fn->(); }
};
$rundiff->([qw(--name-only)], [], sub {
$differs |= $_ eq 'debian' ? DS_DEB : D_UPS;
});
if ($differs & DS_DEB) {
$differs &= ~DS_DEB;
$rundiff->([qw(--name-only -r)], [qw(debian)], sub {
$differs |=
m{^debian/patches/} ? D_PAT_OTH :
$_ eq 'debian/changelog' ? D_DEB_CLOG :
D_DEB_OTH;
});
die "mysterious debian changes $x..$y"
unless $differs & (D_PAT_OTH|DS_DEB);
}
if ($differs & D_PAT_OTH) {
my $mode;
$differs &= ~D_PAT_OTH;
my $pat_oth = sub {
$differs |= D_PAT_OTH;
no warnings qw(exiting); last;
};
$rundiff->([qw(--name-status -r)], [qw(debian/patches/)], sub {
no warnings qw(exiting);
if (!defined $mode) {
$mode = $_; next;
}
die unless s{^debian/patches/}{};
my $ok;
if ($mode eq 'A' && !m/\.series$/s) {
$ok = 1;
} elsif ($mode eq 'M' && $_ eq 'series') {
my $x_s = (git_cat_file "$x:debian/patches/series", 'blob');
my $y_s = (git_cat_file "$y:debian/patches/series", 'blob');
chomp $x_s; $x_s .= "\n";
$ok = $x_s eq substr($y_s, 0, length $x_s);
} else {
# nope
}
$mode = undef;
$differs |= $ok ? D_PAT_ADD : D_PAT_OTH;
});
die "mysterious debian/patches changes $x..$y"
unless $differs & (D_PAT_ADD|D_PAT_OTH);
}
printdebug sprintf "get_differs %s, %s = %#x\n", $x, $y, $differs;
return $differs;
}
sub commit_pr_info ($) {
my ($r) = @_;
return Data::Dumper->dump([$r], [qw(commit)]);
}
sub calculate_committer_authline () {
my $c = cmdoutput @git, qw(commit-tree --no-gpg-sign -m),
'DUMMY COMMIT (git-debrebase)', "HEAD:";
my ($h,$m) = get_commit $c;
$h =~ m/^committer .*$/m or confess "($h) ?";
return $&;
}
sub rm_subdir_cached ($) {
my ($subdir) = @_;
runcmd @git, qw(rm --quiet -rf --cached --ignore-unmatch), $subdir;
}
sub read_tree_subdir ($$) {
my ($subdir, $new_tree_object) = @_;
rm_subdir_cached $subdir;
runcmd @git, qw(read-tree), "--prefix=$subdir/", $new_tree_object;
}
sub make_commit ($$) {
my ($parents, $message_paras) = @_;
my $tree = cmdoutput @git, qw(write-tree);
my @cmd = (@git, qw(commit-tree), $tree);
push @cmd, qw(-p), $_ foreach @$parents;
push @cmd, qw(-m), $_ foreach @$message_paras;
return cmdoutput @cmd;
}
our @fproblem_force_opts;
our $fproblems_forced;
our $fproblems_tripped;
sub fproblem ($$) {
my ($tag,$msg) = @_;
if (grep { $_ eq $tag } @fproblem_force_opts) {
$fproblems_forced++;
print STDERR "git-debrebase: safety catch overridden (-f$tag): $msg\n";
} else {
$fproblems_tripped++;
print STDERR "git-debrebase: safety catch tripped (-f$tag): $msg\n";
}
}
sub fproblems_maybe_bail () {
if ($fproblems_forced) {
printf STDERR
"%s: safety catch trips: %d overriden by individual -f options\n",
$us, $fproblems_forced;
}
if ($fproblems_tripped) {
if ($opt_force) {
printf STDERR
"%s: safety catch trips: %d overriden by global --force\n",
$us, $fproblems_tripped;
} else {
fail sprintf
"%s: safety catch trips: %d blockers (you could -f, or --force)",
$us, $fproblems_tripped;
}
}
}
sub any_fproblems () {
return $fproblems_forced || $fproblems_tripped;
}
# classify returns an info hash like this
# CommitId => $objid
# Hdr => # commit headers, including 1 final newline
# Msg => # commit message (so one newline is dropped)
# Tree => $treeobjid
# Type => (see below)
# Parents = [ {
# Ix => $index # ie 0, 1, 2, ...
# CommitId
# Differs => return value from get_differs
# IsOrigin
# IsDggitImport => 'orig' 'tarball' 'unpatched' 'package' (as from dgit)
# } ...]
# NewMsg => # commit message, but with any [dgit import ...] edited
# # to say "[was: ...]"
#
# Types:
# Packaging
# Changelog
# Upstream
# AddPatches
# Mixed
#
# Pseudomerge
# has additional entres in classification result
# Overwritten = [ subset of Parents ]
# Contributor = $the_remaining_Parent
#
# DgitImportUnpatched
# has additional entry in classification result
# OrigParents = [ subset of Parents ]
#
# Anchor
# has additional entry in classification result
# OrigParents = [ subset of Parents ] # singleton list
#
# BreakwaterStart
#
# Unknown
# has additional entry in classification result
# Why => "prose"
sub parsecommit ($;$) {
my ($objid, $p_ref) = @_;
# => hash with CommitId Hdr Msg Tree Parents
# Parents entries have only Ix CommitId
# $p_ref, if provided, must be [] and is used as a base for Parents
$p_ref //= [];
die if @$p_ref;
my ($h,$m) = get_commit $objid;
my ($t) = $h =~ m/^tree (\w+)$/m or die $objid;
my (@ph) = $h =~ m/^parent (\w+)$/mg;
my $r = {
CommitId => $objid,
Hdr => $h,
Msg => $m,
Tree => $t,
Parents => $p_ref,
};
foreach my $ph (@ph) {
push @$p_ref, {
Ix => scalar @$p_ref,
CommitId => $ph,
};
}
return $r;
}
sub classify ($) {
my ($objid) = @_;
my @p;
my $r = parsecommit($objid, \@p);
my $t = $r->{Tree};
foreach my $p (@p) {
$p->{Differs} = (get_differs $p->{CommitId}, $t),
}
printdebug "classify $objid \$t=$t \@p",
(map { sprintf " %s/%#x", $_->{CommitId}, $_->{Differs} } @p),
"\n";
my $classify = sub {
my ($type, @rest) = @_;
$r = { %$r, Type => $type, @rest };
if ($debuglevel) {
printdebug " = $type ".(dd $r)."\n";
}
return $r;
};
my $unknown = sub {
my ($why) = @_;
$r = { %$r, Type => qw(Unknown), Why => $why };
printdebug " ** Unknown\n";
return $r;
};
my @identical = grep { !$_->{Differs} } @p;
my ($stype, $series) = git_cat_file "$t:debian/patches/series";
my $haspatches = $stype ne 'missing' && $series =~ m/^\s*[^#\n\t ]/m;
if ($r->{Msg} =~ m{^\[git-debrebase anchor.*\]$}m) {
# multi-orig upstreams are represented with an anchor merge
# from a single upstream commit which combines the orig tarballs
my $badanchor = sub { $unknown->("git-debrebase \`anchor' but @_"); };
@p == 2 or return $badanchor->("has other than two parents");
$haspatches and return $badanchor->("contains debian/patches");
# How to decide about l/r ordering of anchors ? git
# --topo-order prefers to expand 2nd parent first. There's
# already an easy rune to look for debian/ history anyway (git log
# debian/) so debian breakwater branch should be 1st parent; that
# way also there's also an easy rune to look for the upstream
# patches (--topo-order).
$p[0]{IsOrigin} and $badanchor->("is an origin commit");
$p[1]{Differs} & ~DS_DEB and
$badanchor->("upstream files differ from left parent");
$p[0]{Differs} & ~D_UPS and
$badanchor->("debian/ differs from right parent");
return $classify->(qw(Anchor),
OrigParents => [ $p[1] ]);
}
if (@p == 1) {
my $d = $r->{Parents}[0]{Differs};
if ($d == D_PAT_ADD) {
return $classify->(qw(AddPatches));
} elsif ($d & (D_PAT_ADD|D_PAT_OTH)) {
return $unknown->("edits debian/patches");
} elsif ($d & DS_DEB and !($d & ~DS_DEB)) {
my ($ty,$dummy) = git_cat_file "$p[0]{CommitId}:debian";
if ($ty eq 'tree') {
if ($d == D_DEB_CLOG) {
return $classify->(qw(Changelog));
} else {
return $classify->(qw(Packaging));
}
} elsif ($ty eq 'missing') {
return $classify->(qw(BreakwaterStart));
} else {
return $unknown->("parent's debian is not a directory");
}
} elsif ($d == D_UPS) {
return $classify->(qw(Upstream));
} elsif ($d & DS_DEB and $d & D_UPS and !($d & ~(DS_DEB|D_UPS))) {
return $classify->(qw(Mixed));
} elsif ($d == 0) {
return $unknown->("no changes");
} else {
confess "internal error $objid ?";
}
}
if (!@p) {
return $unknown->("origin commit");
}
if (@p == 2 && @identical == 1) {
my @overwritten = grep { $_->{Differs} } @p;
confess "internal error $objid ?" unless @overwritten==1;
return $classify->(qw(Pseudomerge),
Overwritten => [ $overwritten[0] ],
Contributor => $identical[0]);
}
if (@p == 2 && @identical == 2) {
my $get_t = sub {
my ($ph,$pm) = get_commit $_[0]{CommitId};
$ph =~ m/^committer .* (\d+) [-+]\d+$/m or die "$_->{CommitId} ?";
$1;
};
my @bytime = @p;
my $order = $get_t->($bytime[0]) <=> $get_t->($bytime[1]);
if ($order > 0) { # newer first
} elsif ($order < 0) {
@bytime = reverse @bytime;
} else {
# same age, default to order made by -s ours
# that is, commit was made by someone who preferred L
}
return $classify->(qw(Pseudomerge),
SubType => qw(Ambiguous),
Contributor => $bytime[0],
Overwritten => [ $bytime[1] ]);
}
foreach my $p (@p) {
my ($p_h, $p_m) = get_commit $p->{CommitId};
$p->{IsOrigin} = $p_h !~ m/^parent \w+$/m;
($p->{IsDgitImport},) = $p_m =~ m/^\[dgit import ([0-9a-z]+) .*\]$/m;
}
my @orig_ps = grep { ($_->{IsDgitImport}//'X') eq 'orig' } @p;
my $m2 = $r->{Msg};
if (!(grep { !$_->{IsOrigin} } @p) and
(@orig_ps >= @p - 1) and
$m2 =~ s{^\[(dgit import unpatched .*)\]$}{[was: $1]}m) {
$r->{NewMsg} = $m2;
return $classify->(qw(DgitImportUnpatched),
OrigParents => \@orig_ps);
}
return $unknown->("complex merge");
}
sub breakwater_of ($) {
my ($head) = @_; # must be laundered
my $breakwater;
my $unclean = sub {
my ($why) = @_;
fail "branch needs laundering (run git-debrebase): $why";
};
for (;;) {
my $cl = classify $head;
my $ty = $cl->{Type};
if ($ty eq 'Packaging' or
$ty eq 'Changelog') {
$breakwater //= $head;
} elsif ($ty eq 'Anchor' or
$ty eq 'BreakwaterStart') {
$breakwater //= $head;
last;
} elsif ($ty eq 'Upstream') {
$unclean->("packaging change ($breakwater)".
" follows upstream change (eg $head)")
if defined $breakwater;
} elsif ($ty eq 'Mixed') {
$unclean->('found mixed upstream/packaging commit ($head)');
} elsif ($ty eq 'Pseudomerge' or
$ty eq 'AddPatches') {
$unclean->("found interchange conversion commit ($ty, $head)");
} elsif ($ty eq 'DgitImportUnpatched') {
$unclean->("found dgit dsc import ($head)");
} else {
fail "found unprocessable commit, cannot cope: $head; $cl->{Why}";
}
$head = $cl->{Parents}[0]{CommitId};
}
return $breakwater;
}
sub walk ($;$$);
sub walk ($;$$) {
my ($input,
$nogenerate,$report) = @_;
# => ($tip, $breakwater_tip, $last_anchor)
# (or nothing, if $nogenerate)
printdebug "*** WALK $input ".($nogenerate//0)." ".($report//'-')."\n";
# go through commits backwards
# we generate two lists of commits to apply:
# breakwater branch and upstream patches
my (@brw_cl, @upp_cl, @processed);
my %found;
my $upp_limit;
my @pseudomerges;
my $cl;
my $xmsg = sub {
my ($prose, $info) = @_;
my $ms = $cl->{Msg};
chomp $ms;
$info //= '';
$ms .= "\n\n[git-debrebase$info: $prose]\n";
return (Msg => $ms);
};
my $rewrite_from_here = sub {
my $sp_cl = { SpecialMethod => 'StartRewrite' };
push @brw_cl, $sp_cl;
push @processed, $sp_cl;
};
my $cur = $input;
my $prdelim = "";
my $prprdelim = sub { print $report $prdelim if $report; $prdelim=""; };
my $prline = sub {
return unless $report;
print $report $prdelim, @_;
$prdelim = "\n";
};
my $bomb = sub { # usage: return $bomb->();
print $report " Unprocessable" if $report;
print $report " ($cl->{Why})" if $report && defined $cl->{Why};
$prprdelim->();
if ($nogenerate) {
return (undef,undef);
}
die "commit $cur: Cannot cope with this commit (d.".
(join ' ', map { sprintf "%#x", $_->{Differs} }
@{ $cl->{Parents} }).
(defined $cl->{Why} ? "; $cl->{Why}": '').
")";
};
my $build;
my $breakwater;
my $build_start = sub {
my ($msg, $parent) = @_;
$prline->(" $msg");
$build = $parent;
no warnings qw(exiting); last;
};
my $last_anchor;
for (;;) {
$cl = classify $cur;
my $ty = $cl->{Type};
my $st = $cl->{SubType};
$prline->("$cl->{CommitId} $cl->{Type}");
$found{$ty. ( defined($st) ? "-$st" : '' )}++;
push @processed, $cl;
my $p0 = @{ $cl->{Parents} }==1 ? $cl->{Parents}[0]{CommitId} : undef;
if ($ty eq 'AddPatches') {
$cur = $p0;
$rewrite_from_here->();
next;
} elsif ($ty eq 'Packaging' or $ty eq 'Changelog') {
push @brw_cl, $cl;
$cur = $p0;
next;
} elsif ($ty eq 'BreakwaterStart') {
$last_anchor = $cur;
$build_start->('FirstPackaging', $cur);
} elsif ($ty eq 'Upstream') {
push @upp_cl, $cl;
$cur = $p0;
next;
} elsif ($ty eq 'Mixed') {
my $queue = sub {
my ($q, $wh) = @_;
my $cls = { %$cl, $xmsg->("split mixed commit: $wh part") };
push @$q, $cls;
};
$queue->(\@brw_cl, "debian");
$queue->(\@upp_cl, "upstream");
$rewrite_from_here->();
$cur = $p0;
next;
} elsif ($ty eq 'Pseudomerge') {
my $contrib = $cl->{Contributor}{CommitId};
print $report " Contributor=$contrib" if $report;
push @pseudomerges, $cl;
$rewrite_from_here->();
$cur = $contrib;
next;
} elsif ($ty eq 'Anchor') {
$last_anchor = $cur;
$build_start->("Anchor", $cur);
} elsif ($ty eq 'DgitImportUnpatched') {
my $pm = $pseudomerges[-1];
if (defined $pm) {
# To an extent, this is heuristic. Imports don't have
# a useful history of the debian/ branch. We assume
# that the first pseudomerge after an import has a
# useful history of debian/, and ignore the histories
# from later pseudomerges. Often the first pseudomerge
# will be the dgit import of the upload to the actual
# suite intended by the non-dgit NMUer, and later
# pseudomerges may represent in-archive copies.
my $ovwrs = $pm->{Overwritten};
printf $report " PM=%s \@Overwr:%d",
$pm->{CommitId}, (scalar @$ovwrs)
if $report;
if (@$ovwrs != 1) {
printdebug "*** WALK BOMB DgitImportUnpatched\n";
return $bomb->();
}
my $ovwr = $ovwrs->[0]{CommitId};
printf $report " Overwr=%s", $ovwr if $report;
# This import has a tree which is just like a
# breakwater tree, but it has the wrong history. It
# ought to have the previous breakwater (which the
# pseudomerge overwrote) as an ancestor. That will
# make the history of the debian/ files correct. As
# for the upstream version: either it's the same as
# was ovewritten (ie, same as the previous
# breakwater), in which case that history is precisely
# right; or, otherwise, it was a non-gitish upload of a
# new upstream version. We can tell these apart by
# looking at the tree of the supposed upstream.
push @brw_cl, {
%$cl,
SpecialMethod => 'DgitImportDebianUpdate',
$xmsg->("convert dgit import: debian changes")
}, {
%$cl,
SpecialMethod => 'DgitImportUpstreamUpdate',
$xmsg->("convert dgit import: upstream update",
" anchor")
};
$prline->(" Import");
$rewrite_from_here->();
$upp_limit //= $#upp_cl; # further, deeper, patches discarded
$cur = $ovwr;
next;
} else {
# Everything is from this import. This kind of import
# is already in valid breakwater format, with the
# patches as commits.
printf $report " NoPM" if $report;
# last thing we processed will have been the first patch,
# if there is one; which is fine, so no need to rewrite
# on account of this import
$build_start->("ImportOrigin", $cur);
}
die "$ty ?";
} else {
printdebug "*** WALK BOMB unrecognised\n";
return $bomb->();
}
}
$prprdelim->();
printdebug "*** WALK prep done cur=$cur".
" brw $#brw_cl upp $#upp_cl proc $#processed pm $#pseudomerges\n";
return if $nogenerate;
# Now we build it back up again
fresh_workarea();
my $rewriting = 0;
my $read_tree_debian = sub {
my ($treeish) = @_;
read_tree_subdir 'debian', "$treeish:debian";
rm_subdir_cached 'debian/patches';
};
my $read_tree_upstream = sub {
my ($treeish) = @_;
runcmd @git, qw(read-tree), $treeish;
$read_tree_debian->($build);
};
$#upp_cl = $upp_limit if defined $upp_limit;
my $committer_authline = calculate_committer_authline();
printdebug "WALK REBUILD $build ".(scalar @processed)."\n";
confess "internal error" unless $build eq (pop @processed)->{CommitId};
in_workarea sub {
mkdir $rd or $!==EEXIST or die $!;
my $current_method;
runcmd @git, qw(read-tree), $build;
foreach my $cl (qw(Debian), (reverse @brw_cl),
{ SpecialMethod => 'RecordBreakwaterTip' },
qw(Upstream), (reverse @upp_cl)) {
if (!ref $cl) {
$current_method = $cl;
next;
}
my $method = $cl->{SpecialMethod} // $current_method;
my @parents = ($build);
my $cltree = $cl->{CommitId};
printdebug "WALK BUILD ".($cltree//'undef').
" $method (rewriting=$rewriting)\n";
if ($method eq 'Debian') {
$read_tree_debian->($cltree);
} elsif ($method eq 'Upstream') {
$read_tree_upstream->($cltree);
} elsif ($method eq 'StartRewrite') {
$rewriting = 1;
next;
} elsif ($method eq 'RecordBreakwaterTip') {
$breakwater = $build;
next;
} elsif ($method eq 'DgitImportDebianUpdate') {
$read_tree_debian->($cltree);
} elsif ($method eq 'DgitImportUpstreamUpdate') {
confess unless $rewriting;
my $differs = (get_differs $build, $cltree);
next unless $differs & D_UPS;
$read_tree_upstream->($cltree);
push @parents, map { $_->{CommitId} } @{ $cl->{OrigParents} };
} else {
confess "$method ?";
}
if (!$rewriting) {
my $procd = (pop @processed) // 'UNDEF';
if ($cl ne $procd) {
$rewriting = 1;
printdebug "WALK REWRITING NOW cl=$cl procd=$procd\n";
}
}
my $newtree = cmdoutput @git, qw(write-tree);
my $ch = $cl->{Hdr};
$ch =~ s{^tree .*}{tree $newtree}m or confess "$ch ?";
$ch =~ s{^parent .*\n}{}mg;
$ch =~ s{(?=^author)}{
join '', map { "parent $_\n" } @parents
}me or confess "$ch ?";
if ($rewriting) {
$ch =~ s{^committer .*$}{$committer_authline}m
or confess "$ch ?";
}
my $cf = "$rd/m$rewriting";
open CD, ">", $cf or die $!;
print CD $ch, "\n", $cl->{Msg} or die $!;
close CD or die $!;
my @cmd = (@git, qw(hash-object));
push @cmd, qw(-w) if $rewriting;
push @cmd, qw(-t commit), $cf;
my $newcommit = cmdoutput @cmd;
confess "$ch ?" unless $rewriting or $newcommit eq $cl->{CommitId};
$build = $newcommit;
if (grep { $method eq $_ } qw(DgitImportUpstreamUpdate)) {
$last_anchor = $cur;
}
}
};
my $final_check = get_differs $build, $input;
die sprintf "internal error %#x %s %s", $final_check, $build, $input
if $final_check & ~D_PAT_ADD;
my @r = ($build, $breakwater, $last_anchor);
printdebug "*** WALK RETURN @r\n";
return @r
}
sub get_head () {
git_check_unmodified();
return git_rev_parse qw(HEAD);
}
sub update_head ($$$) {
my ($old, $new, $mrest) = @_;
runcmd @git, qw(update-ref -m), "debrebase: $mrest", 'HEAD', $new, $old;
}
sub update_head_checkout ($$$) {
my ($old, $new, $mrest) = @_;
update_head $old, $new, $mrest;
runcmd @git, qw(reset --hard);
}
sub update_head_postlaunder ($$$) {
my ($old, $tip, $reflogmsg) = @_;
return if $tip eq $old;
print "git-debrebase: laundered (head was $old)\n";
update_head $old, $tip, $reflogmsg;
# no tree changes except debian/patches
runcmd @git, qw(rm --quiet --ignore-unmatch -rf debian/patches);
}
sub cmd_launder_v0 () {
badusage "no arguments to launder-v0 allowed" if @ARGV;
my $old = get_head();
my ($tip,$breakwater,$last_anchor) = walk $old;
update_head_postlaunder $old, $tip, 'launder';
printf "# breakwater tip\n%s\n", $breakwater;
printf "# working tip\n%s\n", $tip;
printf "# last anchor\n%s\n", $last_anchor;
}
sub defaultcmd_rebase () {
my $old = get_head();
my ($status, $message) = record_ffq_prev();
if ($status eq 'written' || $status eq 'exists') {
} else {
fproblem $status, "could not record ffq-prev: $message";
fproblems_maybe_bail();
}
my ($tip,$breakwater) = walk $old;
update_head_postlaunder $old, $tip, 'launder for rebase';
runcmd @git, qw(rebase), @ARGV, $breakwater;
}
sub cmd_analyse () {
die if ($ARGV[0]//'') =~ m/^-/;
badusage "too many arguments to analyse" if @ARGV>1;
my ($old) = @ARGV;
if (defined $old) {
$old = git_rev_parse $old;
} else {
$old = git_rev_parse 'HEAD';
}
my ($dummy,$breakwater) = walk $old, 1,*STDOUT;
STDOUT->error and die $!;
}
sub ffq_prev_branchinfo () {
# => ('status', "message", [$current, $ffq_prev])
# 'status' may be
# branch message is undef
# weird-symref } no $current,
# notbranch } no $ffq_prev
my $current = git_get_symref();
return ('detached', 'detached HEAD') unless defined $current;
return ('weird-symref', 'HEAD symref is not to refs/')
unless $current =~ m{^refs/};
my $ffq_prev = "refs/$ffq_refprefix/$'";
printdebug "ffq_prev_branchinfo branch current $current\n";
return ('branch', undef, $current, $ffq_prev);
}
sub record_ffq_prev () {
# => ('status', "message")
# 'status' may be
# written message is undef
# exists
# detached
# weird-symref
# notbranch
# if not ff from some branch we should be ff from, is an fproblem
# if "written", will have printed something about that to stdout,
# and also some messages about ff checks
my ($status, $message, $current, $ffq_prev) = ffq_prev_branchinfo();
return ($status, $message) unless $status eq 'branch';
my $currentval = get_head();
my $exists = git_get_ref $ffq_prev;
return ('exists',"$ffq_prev already exists") if $exists;
return ('not-branch', 'HEAD symref is not to refs/heads/')
unless $current =~ m{^refs/heads/};
my $branch = $';
my @check_specs = split /\;/, (cfg "branch.$branch.ffq-ffrefs",1) // '*';
my %checked;
printdebug "ffq check_specs @check_specs\n";
my $check = sub {
my ($lrref, $desc) = @_;
printdebug "ffq might check $lrref ($desc)\n";
my $invert;
for my $chk (@check_specs) {
my $glob = $chk;
$invert = $glob =~ s{^[!^]}{};
last if fnmatch $glob, $lrref;
}
return if $invert;
my $lrval = git_get_ref $lrref;
return unless defined $lrval;
if (is_fast_fwd $lrval, $currentval) {
print "OK, you are ahead of $lrref\n" or die $!;
$checked{$lrref} = 1;
} elsif (is_fast_fwd $currentval, $lrval) {
$checked{$lrref} = -1;
fproblem 'behind', "you are behind $lrref, divergence risk";
} else {
$checked{$lrref} = -1;
fproblem 'diverged', "you have diverged from $lrref";
}
};
my $merge = cfg "branch.$branch.merge",1;
if (defined $merge and $merge =~ m{^refs/heads/}) {
my $rhs = $';
printdebug "ffq merge $rhs\n";
my $check_remote = sub {
my ($remote, $desc) = @_;
printdebug "ffq check_remote ".($remote//'undef')." $desc\n";
return unless defined $remote;
$check->("refs/remotes/$remote/$rhs", $desc);
};
$check_remote->((scalar cfg "branch.$branch.remote",1),
'remote fetch/merge branch');
$check_remote->((scalar cfg "branch.$branch.pushRemote",1) //
(scalar cfg "branch.$branch.pushDefault",1),
'remote push branch');
}
if ($branch =~ m{^dgit/}) {
$check->("refs/remotes/dgit/$branch", 'remote dgit branch');
} elsif ($branch =~ m{^master$}) {
$check->("refs/remotes/dgit/dgit/sid", 'remote dgit branch for sid');
}
fproblems_maybe_bail();
runcmd @git, qw(update-ref -m), "record current head for preservation",
$ffq_prev, $currentval, $git_null_obj;
print "Recorded current head for preservation\n" or die $!;
return ('written', undef);
}
sub cmd_new_upstream_v0 () {
# automatically and unconditionally launders before rebasing
# if rebase --abort is used, laundering has still been done
my %pieces;
badusage "need NEW-VERSION UPS-COMMITTISH" unless @ARGV >= 2;
# parse args - low commitment
my $new_version = (new Dpkg::Version scalar(shift @ARGV), check => 1);
my $new_upstream_version = $new_version->version();
my $new_upstream = git_rev_parse shift @ARGV;
my $piece = sub {
my ($n, @x) = @_; # may be ''
my $pc = $pieces{$n} //= {
Name => $n,
Desc => ($n ? "upstream piece \`$n'" : "upstream (main piece"),
};
while (my $k = shift @x) { $pc->{$k} = shift @x; }
$pc;
};
my @newpieces;
my $newpiece = sub {
my ($n, @x) = @_; # may be ''
my $pc = $piece->($n, @x, NewIx => (scalar @newpieces));
push @newpieces, $pc;
};
$newpiece->('',
OldIx => 0,
New => $new_upstream,
);
while (@ARGV && $ARGV[0] !~ m{^-}) {
my $n = shift @ARGV;
badusage "for each EXTRA-UPS-NAME need EXTRA-UPS-COMMITISH"
unless @ARGV && $ARGV[0] !~ m{^-};
my $c = git_rev_parse shift @ARGV;
die unless $n =~ m/^$extra_orig_namepart_re$/;
$newpiece->($n, New => $c);
}
# now we need to investigate the branch this generates the
# laundered version but we don't switch to it yet
my $old_head = get_head();
my ($old_laundered_tip,$old_bw,$old_upstream_update) = walk $old_head;
my $old_bw_cl = classify $old_bw;
my $old_upstream_update_cl = classify $old_upstream_update;
confess unless $old_upstream_update_cl->{OrigParents};
my $old_upstream = parsecommit
$old_upstream_update_cl->{OrigParents}[0]{CommitId};
$piece->('', Old => $old_upstream->{CommitId});
if ($old_upstream->{Msg} =~ m{^\[git-debrebase }m) {
if ($old_upstream->{Msg} =~
m{^\[git-debrebase upstream-combine \.((?: $extra_orig_namepart_re)+)\:.*\]$}m
) {
my @oldpieces = ('', split / /, $1);
my $parentix = -1 + scalar @{ $old_upstream->{Parents} };
foreach my $i (0..$#oldpieces) {
my $n = $oldpieces[$i];
$piece->($n, Old => $old_upstream->{CommitId}.'^'.$parentix);
}
} else {
fproblem 'upstream-confusing',
"previous upstream $old_upstream->{CommitId} is from".
" git-debrebase but not an \`upstream-combine' commit";
}
}
foreach my $pc (values %pieces) {
if (!$pc->{Old}) {
fproblem 'upstream-new-piece',
"introducing upstream piece \`$pc->{Name}'";
} elsif (!$pc->{New}) {
fproblem 'upstream-rm-piece',
"dropping upstream piece \`$pc->{Name}'";
} elsif (!is_fast_fwd $pc->{Old}, $pc->{New}) {
fproblem 'upstream-not-ff',
"not fast forward: $pc->{Name} $pc->{Old}..$pc->{New}";
}
}
printdebug "%pieces = ", (dd \%pieces), "\n";
printdebug "\@newpieces = ", (dd \@newpieces), "\n";
fproblems_maybe_bail();
my $new_bw;
fresh_workarea();
in_workarea sub {
my @upstream_merge_parents;
if (!any_fproblems()) {
push @upstream_merge_parents, $old_upstream->{CommitId};
}
foreach my $pc (@newpieces) { # always has '' first
if ($pc->{Name}) {
read_tree_subdir $pc->{Name}, $pc->{New};
} else {
runcmd @git, qw(read-tree), $pc->{New};
}
push @upstream_merge_parents, $pc->{New};
}
# index now contains the new upstream
if (@newpieces > 1) {
# need to make the upstream subtree merge commit
$new_upstream = make_commit \@upstream_merge_parents,
[ "Combine upstreams for $new_upstream_version",
("[git-debrebase upstream-combine . ".
(join " ", map { $_->{Name} } @newpieces[1..$#newpieces]).
": new upstream]"),
];
}
# $new_upstream is either the single upstream commit, or the
# combined commit we just made. Either way it will be the
# "upstream" parent of the anchor merge.
read_tree_subdir 'debian', "$old_bw:debian";
# index now contains the anchor merge contents
$new_bw = make_commit [ $old_bw, $new_upstream ],
[ "Update to upstream $new_upstream_version",
"[git-debrebase anchor: new upstream $new_upstream_version, merge]",
];
# Now we have to add a changelog stanza so the Debian version
# is right.
die if unlink "debian";
die $! unless $!==ENOENT or $!==ENOTEMPTY;
unlink "debian/changelog" or $!==ENOENT or die $!;
mkdir "debian" or die $!;
open CN, ">", "debian/changelog" or die $!;
my $oldclog = git_cat_file ":debian/changelog";
$oldclog =~ m/^($package_re) \(\S+\) / or
fail "cannot parse old changelog to get package name";
my $p = $1;
print CN <|', @upd_cmd;
open U, "|-", @upd_cmd or die $!;
my $u = <= ", $_, "\n" foreach split /\n/, $u;
print U $u;
printdebug ">\$\n";
close U or failedcmd @upd_cmd;
}
sub cmd_convert_from_gbp () {
badusage "needs 1 optional argument, the upstream git rev"
unless @ARGV<=1;
my ($upstream_spec) = @ARGV;
$upstream_spec //= 'refs/heads/upstream';
my $upstream = git_rev_parse $upstream_spec;
my $old_head = get_head();
my $upsdiff = get_differs $upstream, $old_head;
if ($upsdiff & D_UPS) {
runcmd @git, qw(--no-pager diff),
$upstream, $old_head,
qw( -- :!/debian :/);
fail "upstream ($upstream_spec) and HEAD are not identical in upstream files";
}
if (!is_fast_fwd $upstream, $old_head) {
fproblem 'upstream-not-ancestor',
"upstream ($upstream) is not an ancestor of HEAD";
} else {
my $wrong = cmdoutput
(@git, qw(rev-list --ancestry-path), "$upstream..HEAD",
qw(-- :/ :!/debian));
if (length $wrong) {
fproblem 'unexpected-upstream-changes',
"history between upstream ($upstream) and HEAD contains direct changes to upstream files - are you sure this is a gbp (patches-unapplied) branch?";
print STDERR "list expected changes with: git log --stat --ancestry-path $upstream_spec..HEAD -- :/ ':!/debian'\n";
}
}
if ((git_cat_file "$upstream:debian")[0] ne 'missing') {
fproblem 'upstream-has-debian',
"upstream ($upstream) contains debian/ directory";
}
fproblems_maybe_bail();
my $work;
fresh_workarea();
in_workarea sub {
runcmd @git, qw(checkout -q -b gdr-internal), $old_head;
# make a branch out of the patch queue - we'll want this in a mo
runcmd qw(gbp pq import);
# strip the patches out
runcmd @git, qw(checkout -q gdr-internal~0);
rm_subdir_cached 'debian/patches';
$work = make_commit ['HEAD'], [
'git-debrebase convert-from-gbp: drop patches from tree',
'Delete debian/patches, as part of converting to git-debrebase format.',
'[git-debrebase convert-from-gbp: drop patches from tree]'
];
# make the anchor merge
# the tree is already exactly right
$work = make_commit [$work, $upstream], [
'git-debrebase import: declare upstream',
'First breakwater merge.',
'[git-debrebase anchor: declare upstream]'
];
# rebase the patch queue onto the new breakwater
runcmd @git, qw(reset --quiet --hard patch-queue/gdr-internal);
runcmd @git, qw(rebase --quiet --onto), $work, qw(gdr-internal);
$work = git_rev_parse 'HEAD';
};
update_head_checkout $old_head, $work, 'convert-from-gbp';
}
sub cmd_convert_to_gbp () {
badusage "no arguments allowed" if @ARGV;
my $head = get_head();
my $ffq = (ffq_prev_branchinfo())[3];
my $bw = breakwater_of $head;
fresh_workarea();
my $out;
in_workarea sub {
runcmd @git, qw(checkout -q -b bw), $bw;
runcmd @git, qw(checkout -q -b patch-queue/bw), $head;
runcmd qw(gbp pq export);
runcmd @git, qw(add debian/patches);
$out = make_commit ['HEAD'], [
'Commit patch queue (converted from git-debrebase format)',
'[git-debrebase convert-to-gbp: commit patches]',
];
};
if (defined $ffq) {
runcmd @git, qw(update-ref -m),
"debrebase: converting corresponding main branch to gbp format",
$ffq, $git_null_obj;
}
update_head_checkout $head, $out, "convert to gbp (v0)";
print <{Type}";
my $keep = 0;
my $p0 = $cl->{Parents}[0]{CommitId};
my $next;
if ($cl->{Type} eq 'Pseudomerge') {
print " ^".($cl->{Contributor}{Ix}+1);
$next = $cl->{Contributor}{CommitId};
} elsif ($cl->{Type} eq 'AddPatches' or
$cl->{Type} eq 'Changelog') {
print " strip";
$next = $p0;
} else {
print " keep";
$next = $p0;
$keep = 1;
}
print "\n";
if ($keep) {
$topmost_keep //= $current;
} else {
die "to-be stripped changes not on top of the branch\n"
if $topmost_keep;
}
$current = $next;
}
if ($topmost_keep eq $old_head) {
print "unchanged\n";
} else {
print "updating to $topmost_keep\n";
update_head_checkout
$old_head, $topmost_keep,
'downstream-rebase-launder-v0';
}
}
GetOptions("D+" => \$debuglevel,
'noop-ok', => \$opt_noop_ok,
'f=s' => \@fproblem_force_opts,
'force!') or die badusage "bad options\n";
initdebug('git-debrebase ');
enabledebug if $debuglevel;
my $toplevel = cmdoutput @git, qw(rev-parse --show-toplevel);
chdir $toplevel or die "chdir $toplevel: $!";
$rd = fresh_playground "$playprefix/misc";
if (!@ARGV || $ARGV[0] =~ m{^-}) {
defaultcmd_rebase();
} else {
my $cmd = shift @ARGV;
my $cmdfn = $cmd;
$cmdfn =~ y/-/_/;
$cmdfn = ${*::}{"cmd_$cmdfn"};
$cmdfn or badusage "unknown git-debrebase sub-operation $cmd";
$cmdfn->();
}