Wednesday, December 30, 2015

Interactive Rebasing in Git

This post is a quick look at one of my favorite features in Git, interactive rebases.  I like this feature because it lets you do two conflicting things: make micro commits (like saving every couple of  minutes when editing a Word doc) so you can replay your work, and always go back to a working state of your code, and making clean, well worded, self contained commits to a project repo.  Interactive rebasing lets you squish your commits together when you are ready to share them.

First a word of warning. Rebasing changes history, and should not be done on work that has been shared with others. But that doesn't mean it shouldn't be done.  It's like proofing your work, and making it clean before you share it.  Good professional practice.

And a technical point, especially for Windows users.  This command uses a text editor as a point of interaction. The Notepad editor that ships with Windows 7 and earlier (and maybe later ones, I haven't checked), garbles the newlines that the Git rebase engine produces, because Git uses Unix style new-lines. Most recent editors (Notepad++, Notepad2, VS Code) can handle this, and if you use Git Extensions (which I recommend), then your Git settings will be set up to use the Git Extensions editor.  You can check whether you have an editor set up by typing:

>git config --global core.editor

Since I have Git-Extensions installed, I get back this:
"C:/Program Files (x86)/GitExtensions/GitExtensions.exe" fileeditor

If you want to use Notepad++, for example, you can type in this (options included to make this function as a standalone interaction point, courtesy of this answer on Stack Overflow: http://stackoverflow.com/a/2486342/402949, also see http://docs.notepad-plus-plus.org/index.php/Command_Line_Switches)

>git config --global core.editor "'C:/Program Files (x86)/Notepad++/notepad++.exe' -multiInst -noplugin -nosesssion -notabbar"

Now lets' get a feel for using interactive rebase. Let's create a repo:

>git init testing

And let's add a file:

>echo file 1 content > file1.txt

And let's add that to the repo and commit it.

>git add file1.txt

>git commit -m "Add file1.txt"

Now let's create another couple of commits....

>echo file 2 content>file2.txt
>git add file2.txt
>git commit -m "Add file2.txt"

>echo file 3 content>file3.txt
>git add file3.txt
>git commit -m "Add file3.txt"

Okay, if you type git log now, you should see something like this:
C:\Users\Dan.Solovay\testing>git log
commit 1462505217add0edfe0451a2f608cbcf72cfbda2
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 18:00:48 2015 -0500

    Add file3.txt

commit 3ad7253ba59e60642af2b0610a9e40c6af4b9f60
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 17:59:21 2015 -0500

    Add file2.txt

commit 78f189fe8f38d80a58ce061e7b43a810674c55df
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 17:58:32 2015 -0500

    Add file1.txt

And let use a more arcane command, but a really good one to know about...

>git reflog
1462505 HEAD@{0}: commit: Add file3.txt
3ad7253 HEAD@{1}: commit: Add file2.txt
78f189f HEAD@{2}: commit (initial): Add file1.txt

Pretty much the same information, presented a little differently. That will soon change.

Let's kick off an interactive rebase.  The nature of the rebase command is that you rebase onto a commit, so we have to leave the initial commit alone, as our building block.  We can launch the interactive rebase two ways:

git rebase 78f189f -i    (You will need to change this to your initial commit.)

Or this:

git rebase "HEAD^^" -i

HEAD means most recent commit, HEAD^ is it's parent, and HEAD^^ is thus your initial commit.  But.... the caret character means line continuation on Windows, so you need to but "HEAD^^" in quotes or you will get a "More?" prompt.  You need to remember this when you read Git documentation and use it on Windows (cmd or PowerShell)..

Either command should cause Notepad++ to open, with this display:

pick 3ad7253 Add file2.txt
pick 1462505 Add file3.txt

# Rebase 78f189f..1462505 onto 78f189f
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

Let's edit the file, by switching the order of the commits, like this:

pick 1462505 Add file3.txt
pick 3ad7253 Add file2.txt


# Rebase 78f189f..1462505 onto 78f189f
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

You should see this message:
Successfully rebased and updated refs/heads/master.

If you don't, you can always get back to your pre rebase state with this command: git rebase --abort

Now lets look at the log:
>git log

commit 61a380892db71cc02db305492867830059c2df22
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 17:59:21 2015 -0500

    Add file2.txt

commit 527a0483703d281628e7f847aec7ecf7ef6babbe
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 18:00:48 2015 -0500

    Add file3.txt

commit 78f189fe8f38d80a58ce061e7b43a810674c55df
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 17:58:32 2015 -0500

    Add file1.txt

You've just rewrote history.  You can see your steps with
>git reflog

61a3808 HEAD@{0}: rebase -i (finish): returning to refs/heads/master
61a3808 HEAD@{1}: rebase -i (pick): Add file2.txt
527a048 HEAD@{2}: rebase -i (pick): Add file3.txt
78f189f HEAD@{3}: rebase -i (start): checkout HEAD^^
1462505 HEAD@{4}: rebase -i (finish): returning to refs/heads/master
1462505 HEAD@{5}: rebase -i (start): checkout HEAD^^
1462505 HEAD@{6}: commit: Add file3.txt
3ad7253 HEAD@{7}: commit: Add file2.txt
78f189f HEAD@{8}: commit (initial): Add file1.txt

Actually, you won't have HEAD@{4} and HEAD@{5}, since those are only there because I forgot to save the first time I tried this. Reflog remembers everything, at least for 90 days. This makes it a very good place to look if you ever can't find a commit (e.g. due to a reset error).

Note the distinction between HEAD^^ (two commits back on this branch) and HEAD@{2} two commits back on this repo on this machine.

We can get back to our pre-rebase state by putting this in another branch:

git branch the-old-master 146205
git checkout the-old-master
git log
Now you will see the original history.

Or, you can create a branch based on the state after you added file3.txt, and before you added file2.txt. Of course, that moment never actually happened, but you photoshopped it into reality with the rebase:

git checkout master
git branch fake_moment "HEAD^"
git checkout fake_moment
dir

12/29/2015  06:34 PM    <DIR>          .
12/29/2015  06:34 PM    <DIR>          ..
12/29/2015  05:58 PM                14 file1.txt
12/29/2015  06:33 PM                16 file3.txt
               2 File(s)             30 bytes

And you can go back to how things were by going back to master:

git checkout master
dir

12/29/2015  06:36 PM    <DIR>          .
12/29/2015  06:36 PM    <DIR>          ..
12/29/2015  05:58 PM                14 file1.txt
12/29/2015  06:36 PM                16 file2.txt
12/29/2015  06:33 PM                16 file3.txt
               3 File(s)             46 bytes

Okay, let's do a little more. Let's combine the last two commits:

git rebase -i "HEAD^^"

And let's change the second commit to a "squash":

pick 527a048 Add file3.txt
s 61a3808 Add file2.txt

# Rebase 78f189f..61a3808 onto 78f189f
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#

# Note that empty commits are commented out


You'll get a chance to edit the combined message:

# This is a combination of 2 commits.
# The first commit's message is:

Add file3.txt

# This is the 2nd commit message:

Add file2.txt

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# rebase in progress; onto 78f189f
# You are currently editing a commit while rebasing branch 'master' on '78f189f'.
#
# Changes to be committed:
# new file:   file2.txt
# new file:   file3.txt

#

Let's accept by saving and closing.  We can see the most recent commit with git show and see the combined message and the combined edits:

>git show
commit 424c80411552e87d1af316e06977e681bdec92a8
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 18:00:48 2015 -0500

    Add file3.txt

    Add file2.txt

commit 78f189fe8f38d80a58ce061e7b43a810674c55df
Author: dsolovay <dsolovay@gmail.com>
Date:   Tue Dec 29 17:58:32 2015 -0500

    Add file1.txt

A few other things you can do:

"f" works just like "s", but doesn't change the first commits message, and doesn't give you a chance to change it. You can also simply remove a commit to remove it from the branch history (including whatever file system changes you may have made.

This may sound cumbersome, but it really gets to be a groove, and allows  you to contribute really well crafted commits with very little effort. Running interactive rebase starts to feel like giving an email a quick read before hitting "Send".  Here is a commit of mine that was originally about 10 or so micro commits: https://github.com/dsolovay/hexo-migrator-rss/commit/5c44479c5027b9f45f39ec188c354df0baecc650, adding tests to a Node.js module.  Having the commits grouped together with a detailed commit message makes it much easier for the person reviewing the pull request to think through.

And even if you never use this technique in your day-to-day coding, I recommend stepping through it at least once or twice with a test repo. It really helps anchor the concepts of local commits vs. upstream commits, and gives you a feeling for the power and flexibility that git offers you as an author of code.

No comments:

Post a Comment