Collapsing commits in Git

There are times with any source control system that changes are committed after which you realize that you made a mistake and need to make a quick change.  In those cases, it seems pretty silly to keep the history for both those commits.

Git supports collapsing commits natively with the git rebase command.  This command provides more than simply collapse functionality, and the collapse functionality appears to be provided only in an interactive mode.  Paralyzed Egg has a good article on how to do this, but there was one pitfall I fell into that wasn’t described there so I’ll provide the detailed instructions here.

The first step is to look at the log to determine which commits you want to collapse:

$ git log -3
commit d43ace68df8a298d205fb422b3261d69bc5d19e4
Author: David Potter <davidp@example.com>
Date:   Sat Mar 26 15:21:29 2011 -0700

    Move README back to the root.

commit 553561e3330ea60db8a3f7baadd08ef116d22129
Author: David Potter <davidp@example.com>
Date:   Sat Mar 26 15:20:46 2011 -0700

    Move project files to the src directory.

commit 41a6af9de1f0e3fb84ae46c301a0252d0f861f02
Author: David Potter <davidp@example.com>
Date:   Tue Mar 22 11:35:14 2011 -0700

    Updated API layer

In this case I had moved one too many files into the src directory and needed to move it back. I wanted to collapse the last two commits into a single commit. To do this, I executed the following command:

$ git rebase -i HEAD~2

This will bring up your editor.

Note that git expects the editor to return when it is done. I had my EDITOR environment variable defined to launch TextMate which returns immediately, which prevents this feature from working.

The contents of the editor will look something like this:

pick 553561e Move project files to the src directory.
pick d43ace6 Move README back to the root.

# Rebase 41a6af9..d43ace6 onto 41a6af9
#
# 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 <cmd>, exec <cmd> = Run a shell command <cmd>, and stop if it fails
#
# If you remove a line here THAT COMMIT WILL BE LOST.
# However, if you remove everything, the rebase will be aborted.
#

To merge those two into a single commit, change the second pick command to squash. Here is the result of that edit:

pick 553561e Move project files to the src directory.
squash d43ace6 Move README back to the root.

# Rebase 41a6af9..d43ace6 onto 41a6af9
#
# 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 <cmd>, exec <cmd> = Run a shell command <cmd>, and stop if it fails
#
# If you remove a line here THAT COMMIT WILL BE LOST.
# However, if you remove everything, the rebase will be aborted.
#

Save the file and exit. Git will bring up the editor with content that looks like this:

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

Move project files to the src directory.

# This is the 2nd commit message:

Move README back to the root.

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Not currently on any branch.
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       renamed:    74-location.png -> src/74-location.png
#       renamed:    ACS-Info.plist -> src/ACS-Info.plist
#       renamed:    ACS.xcodeproj/project.pbxproj -> src/ACS.xcodeproj/project.pbxproj
#       renamed:    ACS.xcodeproj/project.xcworkspace/contents.xcworkspacedata -> src/ACS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
#       renamed:    ACS_Prefix.pch -> src/ACS_Prefix.pch
#       renamed:    Classes/ACSAppDelegate.h -> src/Classes/ACSAppDelegate.h
#       renamed:    Classes/ACSAppDelegate.m -> src/Classes/ACSAppDelegate.m
#       renamed:    Classes/BaseUrlConnection.h -> src/Classes/BaseUrlConnection.h
#       renamed:    Classes/BaseUrlConnection.m -> src/Classes/BaseUrlConnection.m
#       renamed:    Classes/InrixApi.h -> src/Classes/InrixApi.h
#       renamed:    Classes/InrixApi.m -> src/Classes/InrixApi.m
#       renamed:    Classes/MKMapView+ZoomLevel.h -> src/Classes/MKMapView+ZoomLevel.h
#       renamed:    Classes/MKMapView+ZoomLevel.m -> src/Classes/MKMapView+ZoomLevel.m
#       renamed:    Classes/Settings.h -> src/Classes/Settings.h
#       renamed:    Classes/TileOverlay.h -> src/Classes/TileOverlay.h
#       renamed:    Classes/TileOverlay.m -> src/Classes/TileOverlay.m
#       renamed:    Classes/TileOverlayView.h -> src/Classes/TileOverlayView.h
#       renamed:    Classes/TileOverlayView.m -> src/Classes/TileOverlayView.m
#       renamed:    Classes/TrafficViewController.h -> src/Classes/TrafficViewController.h
#       renamed:    Classes/TrafficViewController.m -> src/Classes/TrafficViewController.m
#       renamed:    Classes/TrafficViewController.xib -> src/Classes/TrafficViewController.xib
#       renamed:    GDataXMLNode.h -> src/GDataXMLNode.h
#       renamed:    GDataXMLNode.m -> src/GDataXMLNode.m

The instructions say that all lines beginning with a # will be ignored. That means all lines NOT beginning with a # will be included in the resulting commit message. That wasn’t clear to me when I first read that, maybe because that message is in the middle of the buffer. I changed this buffer to look like this:

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

Move project files to the src directory.

# This is the 2nd commit message:

#Move README back to the root.

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Not currently on any branch.
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       renamed:    74-location.png -> src/74-location.png
#       renamed:    ACS-Info.plist -> src/ACS-Info.plist
#       renamed:    ACS.xcodeproj/project.pbxproj -> src/ACS.xcodeproj/project.pbxproj
#       renamed:    ACS.xcodeproj/project.xcworkspace/contents.xcworkspacedata -> src/ACS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
#       renamed:    ACS_Prefix.pch -> src/ACS_Prefix.pch
#       renamed:    Classes/ACSAppDelegate.h -> src/Classes/ACSAppDelegate.h
#       renamed:    Classes/ACSAppDelegate.m -> src/Classes/ACSAppDelegate.m
#       renamed:    Classes/BaseUrlConnection.h -> src/Classes/BaseUrlConnection.h
#       renamed:    Classes/BaseUrlConnection.m -> src/Classes/BaseUrlConnection.m
#       renamed:    Classes/InrixApi.h -> src/Classes/InrixApi.h
#       renamed:    Classes/InrixApi.m -> src/Classes/InrixApi.m
#       renamed:    Classes/MKMapView+ZoomLevel.h -> src/Classes/MKMapView+ZoomLevel.h
#       renamed:    Classes/MKMapView+ZoomLevel.m -> src/Classes/MKMapView+ZoomLevel.m
#       renamed:    Classes/Settings.h -> src/Classes/Settings.h
#       renamed:    Classes/TileOverlay.h -> src/Classes/TileOverlay.h
#       renamed:    Classes/TileOverlay.m -> src/Classes/TileOverlay.m
#       renamed:    Classes/TileOverlayView.h -> src/Classes/TileOverlayView.h
#       renamed:    Classes/TileOverlayView.m -> src/Classes/TileOverlayView.m
#       renamed:    Classes/TrafficViewController.h -> src/Classes/TrafficViewController.h
#       renamed:    Classes/TrafficViewController.m -> src/Classes/TrafficViewController.m
#       renamed:    Classes/TrafficViewController.xib -> src/Classes/TrafficViewController.xib
#       renamed:    GDataXMLNode.h -> src/GDataXMLNode.h
#       renamed:    GDataXMLNode.m -> src/GDataXMLNode.m

After saving and quitting the editor, Git prints out what it did:

[detached HEAD 419d987] Move project files to the src directory.
 26 files changed, 0 insertions(+), 0 deletions(-)
 rename 74-location.png => src/74-location.png (100%)
 rename ACS-Info.plist => src/ACS-Info.plist (100%)
 rename {ACS.xcodeproj => src/ACS.xcodeproj}/project.pbxproj (100%)
 rename {ACS.xcodeproj => src/ACS.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%)
 rename ACS_Prefix.pch => src/ACS_Prefix.pch (100%)
 rename {Classes => src/Classes}/ACSAppDelegate.h (100%)
 rename {Classes => src/Classes}/ACSAppDelegate.m (100%)
 rename {Classes => src/Classes}/BaseUrlConnection.h (100%)
 rename {Classes => src/Classes}/BaseUrlConnection.m (100%)
 rename {Classes => src/Classes}/InrixApi.h (100%)
 rename {Classes => src/Classes}/InrixApi.m (100%)
 rename {Classes => src/Classes}/MKMapView+ZoomLevel.h (100%)
 rename {Classes => src/Classes}/MKMapView+ZoomLevel.m (100%)
 rename {Classes => src/Classes}/Settings.h (100%)
 rename {Classes => src/Classes}/TileOverlay.h (100%)
 rename {Classes => src/Classes}/TileOverlay.m (100%)
 rename {Classes => src/Classes}/TileOverlayView.h (100%)
 rename {Classes => src/Classes}/TileOverlayView.m (100%)
 rename {Classes => src/Classes}/TrafficViewController.h (100%)
 rename {Classes => src/Classes}/TrafficViewController.m (100%)
 rename {Classes => src/Classes}/TrafficViewController.xib (100%)
 rename GDataXMLNode.h => src/GDataXMLNode.h (100%)
 rename GDataXMLNode.m => src/GDataXMLNode.m (100%)
 rename MainWindow.xib => src/MainWindow.xib (100%)
 rename car_256.png => src/car_256.png (100%)
 rename main.m => src/main.m (100%)
Successfully rebased and updated refs/heads/master.

Here is the result:

$ git log -2
commit 419d987ffbf2c70b1cad664aa2a56281e681268d
Author: David Potter <davidp@example.com>
Date:   Sat Mar 26 15:20:46 2011 -0700

    Move project files to the src directory.

commit 41a6af9de1f0e3fb84ae46c301a0252d0f861f02
Author: David Potter <david@example.com>
Date:   Tue Mar 22 11:35:14 2011 -0700

    Updated API layer

If conflicts occur during this process, you can resolve them and then continue the rebase, like so:

$ git rebase --continue

If you decide you don’t want to continue, you can abort the operation instead:

$ git rebase --abort

You can see the operations that have been performed on the repository using the following command:

$ git reflog --oneline

419d987 HEAD@{0}: rebase -i (squash): Move project files to the src directory.
41a6af9 HEAD@{1}: rebase -i (squash): updating HEAD
553561e HEAD@{2}: checkout: moving from master to 553561e
d43ace6 HEAD@{3}: commit: Move README back to the root.
553561e HEAD@{4}: commit: Move project files to the src directory.
41a6af9 HEAD@{5}: pull origin master: Fast-forward
ce65952 HEAD@{6}: pull origin master: Fast-forward
876b07b HEAD@{7}: pull origin master: Fast-forward
28c415e HEAD@{8}: clone: from git://server/project

2 thoughts on “Collapsing commits in Git

Comments are closed.