Created
February 24, 2020 08:08
-
-
Save dzhu/de91831bda02b1d35bb515f9668137f1 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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