23 June 2020 Tagged in: git | workshop

Git for beginners part 3: moving HEAD

All about reset and why its useful.

Git for beginners part 3: moving HEAD

Several times in the earlier parts, you've seen mysterious references to HEAD, the current commit. HEAD (almost always) is a pointer to a branch, and a branch is a pointer to a commit. The current branch is the one that HEAD points to. Normally, git updates HEAD as it goes along and it does the right thing. But sometimes, you need to take control and move HEAD yourself.

When you switch between branches, HEAD moves to the new branch. When you make a commit, the current branch moves to the new commit and takes HEAD with it.

git reset moves HEAD to another branch, making it the current commit. It also updates the index and working directory (normally).

Let's say you're in the position shown below. You've been working on file.txt and made some changes to get to commit 2acad38. You've now done some more work on that file and realised that what you did in that commit was wrong, a blind alley. You could just create another commit based on the current one, but you'd prefer to keep the history a bit cleaner.

Before doing a reset

Instead, you reset HEAD back to the previous commit,  3c67455 so that you can commit from there.

$ git reset HEAD~ --mixed

(HEAD~ means "the parent of HEAD" and the mixed mode is the default, so it's assumed if you leave it out.)

After giving that command, the situation looks like this:

After doing a mixed reset

HEAD has moved back to its parent. The current branch has also been moved back to the same commit. (Any other branches pointing at 2acad38 would remain there.) The Index has changed to mirror the contents of what is now the current commit. Files in the working directory have remained the same. With a mixed reset, you've not lost any work.

If you now make a new commit from there, you'll end up in this position.

New commit after a reset

HEAD and the current branch both point to the new commit, and the history of that commit points to 3c67455. From the point of view of the main branch, commit 2acad38 never existed.

If there is another branch pointing to 2acad38, that commit will hang around. If not, git will eventually get around to removing it from the history, freeing up that bit of space needed to store the commit.

Other types of reset

That's the default, mixed commit. There are also soft and hard commits. All three types move HEAD; they differ on what they do with the Index and working directory. A soft reset does less than the normal one; a hard reset does more.

  • Soft commit: preserves Index, preserves working directory
  • Mixed commit: changes Index, preserves working directory
  • Hard commit: changes Index, changes working directory

Soft resets: changing (private) history

Soft resets are useful when you want to collapse a sequence of commits into one. Let's say you've been working on some feature and have created a series of commits as you've been working. The new feature is complete, but the history of how you got there is messy.  

If you then soft reset back to the 3c67455 commit, where the main branch points, you get this situation, with feature still being the current branch.

$ git reset --soft main

The soft reset means that the Index contains all the changes in the whole branch. If you create a new commit from here, you get all the changes for the new feature, without the complex history of the work-in-progress.

This isn't the only way of rewriting history: there's a whole process around changing and combining commits, mainly using interactive rebases and the squash feature. But that's outside the scope of this article.

You can do something similar if you forget to use a branch when you started working on a new feature. Create the branch where you are, switch to the main branch, then reset --hard back a few commits. That will leave the feature branch at the tip of the developments, while resetting main back to where it should be. You'll need to do a hard reset so that the working directory is in the state it should be just after the last commit on main. You can then switch back to the feature branch and continue from there.

Hard resets: erasing mistakes

Hard resets change the working directory, the files on disk. This can be very useful to undo major mistakes. If you're working on a feature and end up making a complete mess of everything, you'll want to reset the working directory to something sensible. You can use git restore to restore individual files, but it may be easier to just thow away everything and start again.

If there is an earlier commit that you want to return to, you can use git reset --hard <ID of commit> or git reset --hard HEAD~ to reset to the previous commit. But often, you want to return to the state just after making the current commit. In that case,

git reset --hard HEAD

will restore the Index and working directory to the state you were in immediately after making the most recent commit.

It's something I've used many a time!

Checkout and detached head

The other command that moves HEAD is called checkout. Before Git v2.23, the restore and switch commands didn't exist, and checkout was used for both these purposes. You are likely to see many references to checkout in git resources.

The introduction of restore and switch means that checkout is no longer used for those functions. Instead, checkout is reserved for the action of moving HEAD without also moving the current branch.

The command git checkout <someBranch> does the same thing as git switch <someBranch>: <someBranch> becomes the current branch and HEAD moves to point to it. But you can also checkout a specific commit, such as git checkout 364ed5f. In that case, HEAD points directly at the commit, not at a branch. This is known as a detached head state.

You can work on this commit, make more commits based on it, and so on. But as soon as HEAD moves away from the commit, there will be no reference to it and the commits will be lost. This could be useful for experiments, but it's usually a mistake.

If you get into a detached HEAD situation, the best thing to do is to switch to an existing branch, or create a new branch that points to the current commit. You can do this with git switch -c newBranch, the same as creating any other branch.

The git checkout documentation has more information about the command. I don't want to go into too much detail about it here.

Credit

Thanks to Atilla Sztupak for comments on an earlier version of this article.

Cover photo by unsplash-logoScott Tobin