Chapter 4. Undoing and Editing Commits

In Chapter 3, we discussed staging changes in the index for inclusion in the next commit. This chapter is about undoing or correcting changes once you’ve committed them.

With centralized version control, committing and publishing a change are the same thing: as soon as you commit to the shared repository, others can see and start using your changes. This makes undoing a commit problematic; how do you retract a commit others have already checked out or merged?

With Git, however, this is not a problem, since you are committing to your own private repository. You are free to delete or change your local commits as you please, and Git gives you the tools to do that; publishing those commits is a separate action, via pushing to shared repository or asking others to pull from yours.

Changing already published commits is awkward, of course, since it would cause others to lose history they already have; Git will warn people pulling from you of that, and you might not even be allowed to push such changes to a shared repository. The extra step afforded by Git is crucial, though: by separating committing and publishing, it allows you to use version control freely for your own purposes as you work, then clean up your commits for public consumption before publishing them.

Note that most of the techniques discussed in this chapter only make sense when the portion of history involved is linear; that is, contains no merge commits. We will discuss techniques for editing a branched history in Chapter 10.

Note

Technically, you can’t “change” a commit. Because of content-based addressing, if you change anything about a commit, it becomes a different commit, since it now has a different object ID. So when we speak of changing a commit, we really mean replacing it with one having corrected attributes or contents. But since the intention is to change the history, it’s convenient to use this phrasing, and we’ll do so.

Changing the Last Commit

The most common correction to make is to the previous commit: you run git commit, and then realize you made a mistake—perhaps you forgot to include a new file, or left out some comments. This common situation is also the easiest one to address. There’s no preparatory step; just make whatever corrections you need, adding these to the index as usual. Then use this command:

$ git commit --amend

Git will present you with the previous commit message to edit if you like; then, it simply discards the previous commit and puts a new one in its place, with your corrections. You can add -C HEAD if you want to reuse the previous commit message as-is.

The --amend feature is a good example of how Git’s internal organization makes many operations very easy, both to implement and to understand. The tip commit on the current branch has a pointer to the previous commit, its parent; that is all that links it to the rest of the branch. In particular, no commits point to this one, so no other commits are affected (recall that commits point to their parents, but not to their children). Thus, discarding the tip commit consists only of moving the branch pointer backward to the previous one; nothing else need be done. Eventually, if the discarded commit remains disconnected from the commit graph, Git will delete it from the object database as part of periodic garbage collection.

Having dropped the previous commit, the repository state you want to change and re-commit would appear to be lost…but no: there’s another copy of it in the index, since that commit was made from the current index. So you simply modify the index as desired, and commit again.

Although we described it in terms of a linear history, git commit --amend works with merge commits as well; then there are multiple parents and branches involved instead of just one, but it operates analogously.

Fixing a Commit Message

If you use git commit --amend without making any changes to the index, Git still allows you to edit the commit message if you like, or you can give the new message with the -m option. This still requires replacing the last commit, since the message text is part of the commit; the new commit will just have the same content (point to the same tree) as the previous one.

Double Oops!

Suppose you’re having an off day and, having committed and then amended that commit, you suddenly realize that you just lost some information from the first commit that you didn’t mean to. You would appear to be out of luck: that commit has been discarded, and unless you happen to have its object ID, you have no way to refer to it, even though it’s still in the object database. Git has a feature to save you, though, called the reflog:

$ git log -g

The git log command, which we will discuss in Chapter 9, normally shows the history of your project via portions of the commit graph. The -g option shows something entirely different, however. For each branch, Git maintains a log of operations performed while on that branch, called its “reflog.” Recall that a branch is just a ref pointing to the tip commit of the branch; each ref can have a log recording its referents over time. git log -g displays a composite reflog, starting with the current branch and chaining back through commands that switch branches, such as git checkout. For example:

$ git log -g
e674ab77 HEAD@{0}: commit (amend): Digital Restrictio…
965dfda4 HEAD@{1}: commit: Digital Rights Management
dd31deb3 HEAD@{2}: commit: Mozart
3307465c HEAD@{3}: commit: Beethoven
6273a3b0 HEAD@{4}: merge topic: Fast-forward
d77b78fa HEAD@{5}: checkout: moving from sol to master
6273a3b0 HEAD@{6}: commit: amalthea
2ee20b94 HEAD@{7}: pull: Merge made by the 'recursive…
d77b78fa HEAD@{8}: checkout: moving from master to sol
1ad385f2 HEAD@{9}: commit (initial): Anfang

The reflog shows the sequence of operations performed: commit, pull, checkout, merge, etc. The notation branch@{n} refers to a numbered entry in the reflog for branch; in this case HEAD, the current branch. The crucial thing for us, though, is the first column of object IDs: each one names the commit that was the branch tip after the operation on that line was completed. Thus, when I made the commit for entry #1 in this reflog, with the comment “Digital Rights Management,” the branch moved to commit 965dfda4, which means this is the ID for that commit. After I used git commit --amend to fix the commit message, the branch looked like this:

$ git log
e674ab77 Digital Restrictions Management
dd31deb3 Mozart
3307465c Beethoven
...

Commit 965dfda4 is absent, removed from the history, but the reflog retains a record of it. You can use git show 965dfda4 to view the diff for that commit and recover the missing information, or git checkout 965dfda4 to move your working tree to that state, if that’s more convenient.

See Names Relative to the Reflog for more about the reflog.

Discarding the Last Commit

Suppose you make a commit, but then decide that you weren’t ready to do that. You don’t have a specific fix to make, as with git commit --amend; you just want to “uncommit” and continue working. This is simple; just do:

$ git reset HEAD~
Unstaged changes after reset:
M       Zeus
M       Adonis

git reset is a versatile command, with several modes and actions. It always moves the head of the current branch to a given commit, but differs in how it treats the working tree and index; in this usage, it updates the index but leaves the working tree alone. The HEAD ref refers to the tip of current branch as always, and the trailing tilde names the commit prior to that one (see Chapter 8). Thus, the effect of this command is to move the branch back one commit, discarding the latest one (but you can still recover it via reflog, as before). Since it also resets the index to match, any corresponding changes in your working tree are now unstaged again, which Git reports as shown along with any other outstanding changes (the M is for “modified”; it may also show A for “added,” D for “deleted,” and so on).

Discarding Any Number of Commits

In the foregoing description, the only thing limiting the action to “the last commit” is the expression HEAD~; it works just as well to discard any number of consecutive commits at the end of a branch. This action is sometimes called “rewinding the branch.” For example, to discard three commits resetting the branch tip to the fourth commit back, do:

$ git reset HEAD~3

HEAD~3 refers to the fourth commit back, because this numbering syntax starts at zero; HEAD and HEAD~0 are equivalent.

When discarding more than one commit, some further options to git reset become useful:

--mixed
The default: makes the index match the given commit, but does not change the working files. Changes made since the last commit appear unstaged.
--soft
This resets the branch tip only, and does not change the index; the discarded commit’s changes remain staged. You might use this to stage all the changes from several previous commits, and then reapply them as a single commit.
--merge
Tries to keep your outstanding file changes while rewinding the branch, where this makes sense: files with unstaged changes are kept, while files differing between HEAD and the given commit are updated. If there is overlap between those sets, the reset fails.
--hard
Resets your working files to match the given commit, as well as the index. Any changes you’ve made since the discarded commit are permanently lost, so be careful with this option! Resist the urge to make an alias or shortcut for using git reset --hard; you will probably regret it.

Undoing a Commit

Suppose you want to undo the effect of an earlier commit—you don’t want to edit the history to do this, but rather make a new commit undoing the earlier commit’s changes. The command git revert makes this easy; just give it the commit you want to undo:

$ git revert 9c6a1fad

This will compute the diff between that commit and the previous one, reverse it, and then attempt to apply that to your working tree (you may have merge conflicts to resolve if intervening changes complicate doing that automatically). Git will prepare a commit message indicating the commit being reverted and its subject, which you can edit.

Partial Undo

If you only want to undo some of the changes from an earlier commit, you can use a combination of commands we’ve seen before:

$ git revert -n commit
$ git reset
$ git add -p
$ git commit
$ git checkout .

The -n option to git revert tells Git to apply and stage the reverted changes, but stop short of making a commit. You then unstage all the changes with git reset, and restage only those you want using the interactive git add -p. Finally, after committing the subset of changes you want, you discard the rest by checking out the contents of the index, overwriting the remaining applied changes from git revert.

Plain git revert will complain if you have staged changes in the index (that is, the index does not match the HEAD commit), since its purpose is to make a new commit based on the one to be reverted, and it would lose your changes if it reset the index in order to do that. git revert -n, though, will not complain about that, since it is not making a commit.

Note that if the commit you’re reverting deleted a file, then this will add it back. After git reset though, the recovered file will appear as “untracked” to Git, and git add -p will not see it; you’ll have to add it again separately, if it’s one of the changes you want to make (git add --interactive (-i) can help with that; it’s more general, and git add -p is actually a commonly used subcommand of it). Similarly, the final checkout will not remove a restored file that you chose not to add; you’ll have to remove it yourself. You can use git reset --hard or git clean, but be careful not to accidentally remove other untracked files or revert other working tree changes you may have.

Editing a Series of Commits

git commit --amend is nice, but what if you want to change a commit that is now a few steps back in your history? Since each commit refers to the one preceding it, changing one means all the following commits must be replaced, even if you don’t need to make any other changes to them. The --amend feature works as simply as it does precisely because there are no following commits to consider.

In fact, Git allows you to edit any linear sequence of commits leading up to a branch tip—not only with regard to their messages and contents, but also to rearrange them, remove some, collapse some together or split some into further commits. The feature to use is git rebase. Rebasing is a general technique intended to move a branch from one location to another, and we will consider it more fully in Rebasing. While moving a branch, however, it also lets you use a very general “sequence editor” to transform the branch at the same time (with the option --interactive (-i)), and that is the feature we want here. This command:

$ git rebase -i HEAD~n

rewrites the last n commits on the current branch. It does in fact ask Git to “move” the branch, but the destination is the same as the starting point, so the branch location does not actually change, and you get to use the sequence editor to alter commits as you like in the process.

In response to this command, Git starts your editor and presents a one-line description of each commit in the range indicated, like so:

# action commit-ID subject
pick 51090ce fix bug #1234
pick 15f4720 edit man pages for spelling and grammar
pick 9b0e3dc add prototypes for the 'frobnitz' module
pick 583bb4e fix null pointer (We are not strong.)
pick 45a9484 update README

Watch out: the order here is that in which the commits were made (and in which they will be remade), which is generally the opposite of what you would see from git log, which uses reverse chronological order (most recent commit first).

Now edit the first column, the action, to tell Git what you want to do with each commit. The available actions are:

pick
Use the commit as-is. Git will not stop for this commit unless there is a conflict.
reword
Change just the commit message. Git allows you to edit the message before reapplying this commit.
edit
Change the commit contents (and message, if you want). Here, Git stops after remaking this commit and allows you to do whatever you want. The usual thing is to use git commit --amend to replace the commit, then git rebase --continue to let Git continue with the rebase operation. However, you could also insert further commits, perhaps splitting the original changes up into several smaller commits. Git simply picks up from where you leave off, with the next change you asked it to make.
squash
Make this commit’s changes part of the preceding one. To meld several consecutive commits into one, leave the first one marked pick and mark the remaining ones with squash. Git concatenates all the commit messages for you to edit.
fixup
Like squash, but discard the message of this commit when composing the composite message.

You can abbreviate an action to just its initial letter, such as r for reword. You can also reorder the lines to make the new commits in a different order, or remove a commit entirely by deleting its line. If you want to cancel the rebase, just save a file with no action lines; Git will abort if it finds nothing to do. It will not abort if you just leave the directions as you found them, but the result will be the same in this simple case, since Git will find it does not need to remake any commits in order to follow the directions (which say to use each commit as-is with pick). At any point when Git stops, you can abort the entire process and return to your previous state with git rebase --abort.

Conflicts

It’s possible to ask for changes that invalidate the existing commits. For example: if one commit adds a file and a later commit changes that file, and you reverse the order of these commits, then Git cannot apply the new first patch, since it says to alter a file that doesn’t yet exist. Also, patches to existing files rely on context, which may change if you edit the contents of earlier commits. In this case, Git will stop, indicate the problem, and ask you to resolve the conflict before proceeding. For example:

error: could not apply fcff9f72... (commit message)

When you have resolved this problem, run "git rebase
--continue".  If you prefer to skip this patch, run
"git rebase --skip" instead.  To check out the
original branch and stop rebasing, run "git rebase
--abort".

Could not apply fcff9f7... (commit message)

Here, Git uses the same mechanism for indicating conflicts as when performing a merge; see Merge Conflicts for details on how to examine and resolve them. When you’re done, as indicated above, just run git rebase --continue to make the now-repaired commit, and move on to the next edit.

Tip

When you ask to edit a commit, Git stops after making the commit, and you use git commit --amend to replace it before going on. When there’s a conflict, however, Git cannot make all the requested changes, so it stops before making the commit (having made and staged whatever changes it can, and marked the conflicts for you to resolve). When you continue after resolving the conflicts, Git will then make the current commit. You do not commit yourself or use the --amend feature when fixing a conflict.

The exec Action

There is actually another action, exec, but you would not edit an existing line in the rebase instructions to use it as with the other actions, since it does not say to do anything with a commit; rather, the rest of the line is a just shell command for Git to run. A typical use for this is to test the preceding commit in some way, to make sure you haven’t accidentally broken the content; you might add exec make test, for example, to run an automated software test. Git will stop if an exec command exits with a nonzero status. You can also give a single command with the git rebase --exec option, which will be run after every commit; this is a shortcut for inserting that same exec line after every commit in the sequencer directions.

Get Git Pocket Guide now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.