This is my first attempt at using "plumbing" commands, so feel free to suggest improvements.
The two powerful commands you can use here, to figure out how to move this tree, are git for-each-ref
and git rev-list
:
git for-each-ref refs/heads --contains B
gives you all (local) refs you'd want to move, that is feature
, new
, other
git rev-list ^B^ feature new other
gives you all commits you want to move.
Now thanks to rebase, we don't really need to move every commit, only the nodes in your tree that are leaves or forks. Thus those that have zero or 2 (or more) child commits.
Let us assume, for the sake of consistency with rebase that you give arguments as:
git transplant-onto H A
, then you could use the following (and put it under git-transplant-onto
in your path):
#!/usr/bin/env bash
function usage() {
echo
echo "Usage: $0 [-n] [-v] "
echo " Transplants the tree from to "
echo " i.e. performs git rebase --onto [, for each ][ in "
echo " while maintaining the tree structure inside "
exit $1
}
dry_run=0
verbose=0
while [[ "${1::1}" == "-" ]]; do
case $1 in
-n) dry_run=1 ;;
-v) verbose=1 ;;
*) echo Unrecognized option $1; usage -1 ;;
esac
shift
done
# verifications
if (( $# < 2 )) || ! onto=$(git rev-parse --verify $1) || ! from=$(git rev-parse --verify $2); then usage $#; fi
git diff-index --quiet HEAD || {echo "Please stash changes before transplanting"; exit 3}
# get refs to move
shift 2
if (( $# == 0 )); then
refs=$(git for-each-ref --format "%(refname)" refs/heads --contains $from)
else
refs=$(git show-ref --heads --tags $@ | cut -d' ' -f2)
if (( $# != $(echo "$refs" | wc -l) )); then echo Some refs passed as arguments were wrong; exit 4; fi
fi
# confirm
echo "Following branches will be moved: "$refs
REPLY=invalid
while [[ ! $REPLY =~ ^[nNyY]?$ ]]; do read -p "OK? [Y/n]"; done
if [[ $REPLY =~ [nN] ]]; then exit 2; fi
# only work with non-redundant refs
independent=$(git merge-base --independent $refs)
if (( verbose )); then
echo INFO:
echo independent refs:;git --no-pager show -s --oneline --decorate $independent
echo redundant refs:;git --no-pager show -s --oneline --decorate $(git show-ref $refs | grep -Fwvf <(echo "$independent") )
fi
# list all commits, keep those that have 0 or 2+ children
# so we rebase only forks or leaves in our tree
tree_nodes=$(git rev-list --topo-order --children ^$from $independent | sed -rn 's/^([0-9a-f]{40})(( [0-9a-f]{40}){2,})?$/\1/p')
# find out ancestry in this node list (taking advantage of topo-order)
declare -A parents
for f in $tree_nodes; do
for p in ${tree_nodes#*$h} $from; do
if git merge-base --is-ancestor $p $h ; then
parents[$h]=$p
break
fi
done
if [[ ${parents[$h]:-unset} = unset ]]; then echo Failed at finding an ancestor for the following commit; git --no-pager show -s --oneline --decorate $h; exit 2; fi
done
# prepare to rebase, remember mappings
declare -A map
map[$from]=$onto
# IMPORTANT! this time go over in chronological order, so when rebasing a node its ancestor will be already moved
while read h; do
old_base=${parents[$h]}
new_base=${map[$old_base]}
git rebase --preserve-merges --onto $new_base $old_base $h || {
git rebase --abort
git for-each-ref --format "%(refname:strip=2)" --contains $old_base refs/heads/ | \
xargs echo ERROR: Failed a rebase in $old_base..$h, depending branches are:
exit 1
}
map[$h]=$(git rev-parse HEAD)
done < <(echo "$tree_nodes" | tac)
# from here on, all went well, all branches were rebased.
# update refs if no dry-run, otherwise show how
ref_dests=
for ref in $refs; do
# find current and future hash for each ref we wanted to move
# all independent tags are in map, maybe by chance some redundant ones as well
orig=$(git show-ref --heads --tags -s $ref)
dest=${map[$orig]:-unset}
# otherwise look for a child in the independents, use map[child]~distance as target
if [[ $dest = unset ]]; then
for child in $independent; do
if git merge-base --is-ancestor $ref $child ; then
dest=$(git rev-parse ${map[$child]}~$(git rev-list $ref..$child | wc -l) )
break
fi
done
fi
# finally update ref
ref_dests+=" $dest"
if (( dry_run )); then
echo git update-ref $ref $dest
else
git update-ref $ref $dest
fi
done
if (( dry_run )); then
echo
echo If you apply the update-refs listed above, the tree will be:
git show-branch $onto $ref_dests
else
echo The tree now is:
git show-branch $onto $refs
fi
]
Another way is to get all individual commits with their parent in an order that you may transpose (say git rev-list --topo-order --reverse --parents
) and then use git am
on each individual commit.