Understanding checkout and reset

git checkout and git reset are heavily overloaded commands that can be hard to understand because many similar-looking commands do subtly different things. This is my attempt to describe the different variants as simply and concisely as possible.

Target audience: people who understand the concepts of index vs. working tree, history graph, refs etc.

Path vs. no path

The most important distinction is that both commands have two forms: one with a path argument and one without. Here are some examples of commands without a path argument:

git checkout
git checkout next
git checkout -
git reset
git reset --hard
git reset --hard HEAD~5
And here are some with paths:
git checkout .
git checkout HEAD test
git reset .
git reset HEAD~5 foo bar baz

The four basic variants

Two commands, two forms each, that gives us four categories (the ones without path being more common in everyday usage):
Without pathWith path
checkoutSwitch branchesOverwrite working tree file(s) from index or commit
resetOverwrite a branch and/or the full index/working tree with a specific versionOverwrite index entries with a specific version

With paths

Let's get the easy part out of the way. Whenever you specify paths, obviously only the paths in question are affected (so this will never fully switch a branch, nor change what commit a branch points to), but exactly what is affected depends on the command and which options are provided. Whenever I say <commit>, it could also mean a branch name or e.g. HEAD. The -- is an optional separator to disambiguate (e.g. if you have a path that has the same name as a ref. It's a good habit to always use the separator).

CommandTake versions fromUpdate the indexUpdate the working treeChange/switch current branch
git checkout [--] <path>indexno*YESno
git checkout <commit> [--] <path>commitYESYES
git reset [--] <path>HEADYESno
git reset <commit> [--] <path>commitYESno

(*) Technically you could say that the index entry is updated with itself, but effectively nothing changes.

So, a simple takeaway is that git checkout with path always updates the working tree and git reset with path never updates the working tree. Another interesting takeaway is that there is no straightforward command to overwrite a working tree file with a version from a specific commit without also overwriting the index entry. You have to resort to trickery to achieve that.

Checkout without path

Example: git checkout next

This switches your working tree over to a different branch. If you have any uncommitted changes, they are kept (i.e. you'll be on the new branch and still have the same uncommitted changes). If this is not possible without making potentially destructive changes, checkout will refuse to do anything. You can use -f to discard all uncommitted changes, or -m to try and apply the uncommitted changes to the new branch using a file merge operation, potentially creating conflict markers etc.

Reset without path

This is the hardest to explain, because it has two main purposes rolled into one: changing what a branch points to, or doing a blanket rollback of changes in the index and/or the working tree. Or both...

Whether the current branch's target commit is changed (which is most often used to change it to an older commit to effectively remove commits from the branch) depends on whether a <commit> argument is provided. If this is not given, it defaults to HEAD – that's precisely what the branch is currently pointing to, so by default the branch doesn't really change.

So, whenever you don't actually want to touch the branch pointer, you leave out the <commit> argument, at which point the options become more interesting. If you use both the argument and the options, reset does both: first it overwrites the branch pointer, then it does whatever the options tell it to do.

A typical example where the second purpose (rolling back changes in the index/working tree) is what we care about is git reset --hard. Here, the branch pointer does not change, but --hard tells reset to rollback all changes in the index and the working tree. Effectively, this means "remove all uncommitted changes from the index and working tree". Here's the full overview:

CommandChange branch pointerRoll back indexRoll back working treeSource version
git reset --softno*nonoHEAD
git reset
git reset --mixed
no*YESnoHEAD
git reset --hardno*YESYESHEAD
git reset --soft <commit>YESnonocommit
git reset <commit>
git reset --mixed <commit>
YESYESnocommit
git reset --hard <commit>YESYESYEScommit

(*) Technically you could say that the branch pointer is updated with its current value, but effectively nothing changes.

If you've been paying attention, you might have noticed that git reset --soft without a <commit> argument does... nothing. That's right! --soft is fairly useless in that situation.