Interactive git rebase with non-interactive editing

When working with git and especially GitHub, I often have commits on my local branch that were already submitted as a pull request. Sometimes I continue working and later notice that I have commits on the branch that have nothing to do with the next thing I am already working on. Therefore I want to remove them from the current branch.

$ git log --oneline @{upstream}..
e159d1e Commit C
70140e3 Commit B
16ed14a Commit A

Let’s say I want to get rid of Commit B. The normal way would be to use git rebase -i and then tell git to remove the commit by deleting the corresponding line or replacing pick with drop, then save the file, exit the editor and let git do the rest. However, if I already know exactly what I want to do, this seems cumbersome. Therefore I came up with the following alias:

$ git config --global alias.drop '!f() { for c in "$@"; do sha1=$(git rev-parse --short $1); [ -n "$sha1" ] && git -c rebase.abbreviateCommands=false -c sequence.editor="printf \"%s\n\" \"g/^pick $sha1 /s//drop $sha1 /\" w \"g/^#/d\" \"g/^$/d\" \"%p\" Q | ed -s" rebase -i --autostash; done }; f'

I admit that maybe at some point I should have changed my plan and I should have made this into a shell script instead… But this works and requires no extra files than just ~/.gitconfig.

What does it do? This alias is not a normal alias, but as indicated by the ! at the start it is supposed to be executed in a shell. As git will append all additional arguments from the command line to the end of this shell command, I turned it into a shell function to be able to handle the arguments one after another in a loop. First, the given argument is validated as a reference to a commit and the matching sha1 is retrieved. This means it can also understand HEAD~3 and similar references. If it is not valid, git will already have printed an error message. If it is valid, this runs git rebase with some special options.

The configuration option rebase.abbreviateCommands ensures that git will show us the long command name pick and not just p in the editor. But wait, there is also sequence.editor, which will be used instead of your normal $EDITOR. This is a custom command that will get a temporary filename as an argument. As the file needs to be edited in place, I chose ed for this task. Other options like sed or awk might work as well, but as far as I know there is no standardized interface for in-place editing and I want this to work with all flavors of Linux or *BSD.

ed takes a filename as argument and then reads line-wise commands from standard input that are applied to the file. I wrapped this with a printf to avoid using lots of \n in one long string or using many echo commands instead. The first command is g/re/... which will search for a line matching the given regex and execute the commands following the second slash. As a side note, the name of the tool grep comes exactly from this ed command: g/re/p, where the p stands for print. The command executed here is s/re/replacement/ which will replace the first occurrence of the regex with the given replacement. The empty string denotes the previous match. This combination of two commands is necessary to mask errors from ed. If a s// does not replace anything, ed would return an error and exit with a non-zero status code. This behavior is masked by combining it with g//, which will only try to replace on lines that already matched.

The command w will write the file to disk. Then I also wanted to get some output of the results to quickly see what’s going on. The next two g// commands will delete comments and empty lines before %p will print the whole file to standard output. Finally, the last command Q tells ed to quit without saving.

git will then take the modified file just as if it was edited in your $EDITOR and continue with the rebase. The --autosquash option is useful to automatically stash any uncommitted changes before running the rebase and then restoring them afterwards.

Now let’s finally get to removing Commit B from the example above:

$ git drop 70140e3
pick 16ed14a Commit A
drop 70140e3 Commit B
pick e159d1e Commit C
Successfully rebased and updated refs/heads/master.

Here it goes! This was an interactive rebase, but with non-interactive editing of the sequence file using ed. Setting up a similar alias for git reword to quickly edit the commit message is left as an exercise for the reader.

Update 2020-09-17: Replaced the final q command with Q, as some implementations of ed would exit with a non-zero exit code if the buffer was modified and not saved.

3 thoughts on “Interactive git rebase with non-interactive editing

  1. Thomas Guyot

    Nice writeup. It inspired me for a tool, however instead of setting git config values you could simply pass the GIT_SEQUENCE_EDITOR environment variable to git, so next time you wish to rebase interactively it will not try to run the last rebase.

    Personally I use sed, and the best feature of all in there is the possibility to execute a command for rebase tasks. My script re-creates some known commits on testing branches after pulling new files from other sources. It looks somewhat like this:

    GIT_SEQUENCE_EDITOR=”/bin/sed -ri -f $sedtmpfile” git rebase -i $mergebase

    The sed commands could be inline but I though it was a bit cleaner this way… the rules contain one or more lines that looks like this:

    s#^p(ick)?\s(\S+)\s${match}$#${cmd} \\2#

    So I match on commit subject line, and can run a script + arguments. That script receives the commit as argument too where it can inspect subject and contents to further process it.

  2. Thomas Guyot

    Update: the ${cmd} above actually include the tasklist command; to execute something, you do “x ” – everything after “x” is interpreted as a command.

    It would have been clearer this way:
    s#^p(ick)?\s(\S+)\s${match}$#x ${cmd} \\2#

  3. Rainer Müller Post author

    Thomas, you’re welcome, I am glad this post inspired you! I was not aware of GIT_SEQUENCE_EDITOR, but setting the environment variable or the git config option on command line should make no difference. Therefore I do not understand what you mean by “it will not try to run the last rebase” as neither is persistent.

    With ‘sed -ri’ you are using two options that are only compatible with GNU sed. They may not work with other implementations of sed(1) on BSD, macOS, or on any other platform. Compatiblity was a goal as explained in the post and I still could not find any other less complicated variant without additional temporary file than to use ed(1) for in-place editing.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.