Skip to content

Instantly share code, notes, and snippets.

@dzhu
Created February 24, 2020 08:08
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save dzhu/de91831bda02b1d35bb515f9668137f1 to your computer and use it in GitHub Desktop.
Save dzhu/de91831bda02b1d35bb515f9668137f1 to your computer and use it in GitHub Desktop.
#!/bin/bash
# This script performs a git rebase across a commit that contains the changes
# generated by running a command (typically some sort of formatter; we'll use
# "formatter" to describe it from now on) on the contents of the repo; each new
# commit, after the rebase contains the contents of the corresponding original
# commit plus the effects of the command.
# This script takes two arguments: the ref containing the code to be rebased and
# the commit containing the formatter changes.
# A plain rebase would generally lead to many conflicts, but this script avoids
# that by carefully applying and reverting the effect of the formatter during a
# rebase so that all of the original commits can be replayed cleanly.
# Conceptually, you can think of the process as a pair of interactive rebases.
# In the first rebase, add a new pair of commits after each input commit: one
# that applies the formatter on top of it and one that exactly reverts that new
# commit. In the second rebase, the commits are squashed, without changing their
# order, so that each resulting commit contains a formatter reversion, an
# original commit, and a formatter application. Clearly, there is never a chance
# for any conflicts to arise. Via some jiggery-pokery, it is in fact possible to
# do the whole process inside one rebase.
# During the rebase, we maintain the invariant that, before any given commit is
# replayed, (1) HEAD has the same contents as that commit's original parent, so
# that the commit can always apply cleanly, and (2) HEAD itself is a commit that
# just reverts the effect of the formatter. By doing the right things after each
# commit is replayed, we can emulate the effect of the double rebase.
set -o errexit
set -o nounset
#### The command that generates the changes to apply.
formatter_cmd=(black .)
#### Things to do during the rebase itself. To keep everything self-contained,
#### this script just calls itself with different arguments to edit the rebase
#### todo list and munge the repo during the rebase. All those cases are handled
#### here; the top-level logic comes afterward.
# Set up the todo list so that this script is called with an argument of
# `--do-rebase-first` before the first commit and then again with an argument of
# `--do-rebase` after each commit. Also, run `git reset --hard @^` at the end,
# which drops the one last formatter-reverting commit.
if [ "$1" = --edit-todo ]; then
sed -i -n \
-e "1i \\" -e "exec \"$0\" --do-rebase-first" \
-e p \
-e "/^pick/a \\" -e "exec \"$0\" --do-rebase" \
-e "\$a \\" -e 'exec git reset --hard @^' \
"$2"
exit 0
fi
# Before the first commit is replayed, HEAD is the formatter application commit.
# Revert that commit, setting up the invariant.
if [ "$1" = --do-rebase-first ]; then
git revert @
exit 0
fi
# Now we get to the main rebase logic. This block is run after each commit is
# replayed.
if [ "$1" = --do-rebase ]; then
# Merge the top two commits (the one that just got replayed and the
# formatter-reverting one described by the invariant). We can't use rebase
# because we're already in the middle of a rebase and they don't nest.
c="$(git rev-parse @)"
git reset --soft @^^
git commit -C "$c"
# Apply the formatter.
"${formatter_cmd[@]}"
# In order to produce the formatter-reverting commit that we'll need before
# replaying the next commit, save the inverse of the diff that the formatter
# just produced.
diff="$(git diff -R)"
# Meld the formatter changes into the replayed commit, putting the commit
# into its final form.
git commit --all --amend --no-edit
# Create the formatter-reverting commit for the next round.
git apply - <<<"$diff"
git commit --all --message=.
exit 0
fi
#### Define helper functions.
# https://stackoverflow.com/questions/3231804
function confirm() {
read -r -p "${1:-Are you sure? [y/N]} " response
case "$response" in
[yY][eE][sS]|[yY])
true
;;
*)
false
;;
esac
}
function die() {
echo -e "\x1b[1m$1\x1b[m"
exit 1
}
#### Set up parameters.
## Check that arguments were provided.
[ -n "${1:-}" ] || die "You must give the work branch as the first argument!"
[ -n "${2:-}" ] || die "You must give the formatter commit as the second argument!"
## The branch containing the changes to update.
START="$1"
## The commit introducing the formatting and its parent.
TARGET="$(git rev-parse "$2")"
PRETARGET="$(git rev-parse "$TARGET"^)"
#### Check the current state.
## Make sure the working directory is clean to keep things simple.
git diff --quiet || die "You have uncommitted unstaged changes!"
git diff --quiet --cached || die "You have uncommitted staged changes!"
## Check whether the branch is already a descendant of the target, in which case
## we're done.
if git merge-base --is-ancestor "$TARGET" "$START"; then
die "$START is already a descendant of the formatting commit $TARGET!"
fi
## If we need to, rebase onto the parent of the formatting commit, so we are
## only dealing with the formatting commit from now on.
if ! git merge-base --is-ancestor "$PRETARGET" "$START"; then
git checkout "$START"
confirm "Rebasing onto parent of the formatting commit via \`git rebase $PRETARGET\`. Continue? [y/N]"
git rebase "$PRETARGET" || die "Conflicts encountered; finish dealing with the rebase first!"
fi
#### Finally, do the thing. The actual logic is contained in the code at the top
#### of this file; all that's left to do here is to kick it off.
GIT_EDITOR="\"$0\" --edit-todo" git rebase -i "$TARGET"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment