Git Reset Modes

Sometimes when working with Git it would be useful to rewind your branch to an earlier commit. Maybe one of your recent commits introduced a bug or you just want to organize your changes differently between commits. Whatever the reason, Git provides this functionality through the git reset command.

In this article I will explain and demonstrate how to use the three most common modes of the git reset command, soft, mixed, and hard.

Important Background

Before diving into what git reset is and how it works, we need to have a basic understanding of these four important pieces of Git: working directory, repository, index, and HEAD.

Working Directory

The easiest piece to understand is the working directory, a.k.a. workspace. This is simply the main directory where all the source files live that you see and edit for your project.

Repository

Typically, when we are working on a project that Git is managing, the working directory houses another directory named .git. This directory is the repository and contains all the commit history, branch information, files, etc. for Git to manage the project. The .git directory is also all that's needed to rebuild (i.e. clone) the repository.

From Git's perspective the .git directory is the repository. However, the term repository is often used more generally to mean the working directory and everything inside it. The important point is that when Git references the repository it means the .git directory.

HEAD

The HEAD is a pointer to the latest commit in the commit history on the current branch reference. Each branch has only one HEAD that is unique to that specific branch. While often used interchangeably for convenience, branch and HEAD aren't the same, because a branch represents a collection of commits, while the HEAD is specifically the latest commit on the branch.

The repository stores all head references in .git/refs/heads/. Also, there is a HEAD file in the root of .git/ that points to a specific head reference in .git/refs/heads.

Index

The index is the storage space for all changes that will be included in the next commit and is stored within the repository. The index might also be referred to as the staging area, or cache. When we use the git add command, the file is then placed in the index.

Putting the Pieces Together

For visual clarity, below is a simple diagram of how these pieces are related. The working directory encompasses everything, then the repository(.git directory) contains the index and the HEAD.

Diagram of how the working directory, index, reposiotry, and head are related to each other.

The File Lifecycle

The example below demonstrates the three main stages for a file when working with Git. It starts in the working directory where It's either a new file or an existing file that has been modified. 1 Next, when the file is done being modified, it's ready to be added to the index using the git add command.2 Lastly, it is ready to be committed with git commit. Once committed it becomes part of the latest commit, and this latest commit becomes the new HEAD.3

Example of the file lifecycle as it transitions from the working directory to the index and finally to the commit history

It's important to know that none of the files are actually getting moved around. Instead, copies of files are being made (it's more complicated than a basic copy and paste, but for modeling purposes copy is accurate enough). All the files in the working directory stay in the working directory. When the file is staged, a copy of it is placed in the index. Then, when the file is committed, another copy is created and added to the repository as a part of the commit history.

With that being said, it is entirely possible and not uncommon for three different versions of a file to exist at a time between the working directory, index, and HEAD. Take for example, the figure below. Initially, the file is created/edited, staged, and then committed. Meaning all three areas have the same file.1 If the file is edited and staged again, but not committed, then the working directory and the index will have a different version of the file than the HEAD.2 Finally, if the file is edited again but not staged, then the working directory now contains a third version of the same file.

Example of the same file existing in the working directory, index, and HEAD at the same time

The Reset Command

A reset command has the following template, where MODE can be soft, mixed, or hard, and the COMMIT_HASH is the hash ID for a commit that can be found when viewing the log.

git reset --MODE COMMIT_HASH

E.x.

git reset --soft 78dffe9c5fa346d03458d15862a38325ebb2e896

At a minimum, a reset is just moving the head back to a previous commit. However, an important thing to consider is what happens to the commits between the new HEAD position and the old HEAD position. Each of those commits contain changes, and what happens to those changes is determined by the different reset modes.

This is most easily seen through an example. Consider the following branch, my_example_branch, from the figure below.1 If for some reason we don't like commits D and E and we want to undo them, we can do a git reset C to move the head back to commit C.2 This makes commits D and E orphaned and no longer part of the branch's commit history.

Example of how a branch changes after a git reset

Soft Reset

For a soft reset, all current changes in the index remain in the index, all changes in the orphaned commits are placed back in the index, and the working directory is unchanged.

The example below demonstrates this. We start with a series of five commits: A, B, C, D, and E, all with various changes.1 The notation F1A specifies version A of a file named F1. It's important to note that in this state we also have changes in the working directory with F3C that haven't been staged, and changes in the index with F2E that haven't been committed.

Performing a git reset --soft C2 moves the HEAD back to commit C. This causes commits D and E to be orphaned. Commit D has changes F1C and F3B placed back into the index. Notice that F3C and F3B now exist simultaneously in the working directory and index, respectively. Commit E has change F4A placed back into the index. However, change F2D is a little different. Since F2E already exists in the index, and is more recent than F2D, the more recent change is kept.

Example of how a soft reset affects the HEAD, index, and working directory

Example - Setup Commands

mkdir soft_reset       
cd soft_reset
git init
touch F1
echo "F1A" >> F1
touch F2
echo "F2A" >> F2
git add .
git commit -m "Commit A"
touch F3
echo "F3A" >> F3
echo "F2B" >> F2 
git add .
git commit -m "Commit B"
echo "F1B" >> F1
echo "F2C" >> F2
git add .
git commit -m "Commit C"
echo "F1C" >> F1
echo "F3B" >> F3
git add .
git commit -m "Commit D"
echo "F2D" >> F2
touch F4
echo "F4A" >> F4
git add .
git commit -m "Commit E"
echo "F2E" >> F2
git add F2
echo "F3C" >> F3

Example - Setup State

$ git log --name-only

commit b8ed5b6fb95681eb02a1de265a374ef300ad27b9 (HEAD -> main)
Author: ...
Date: ...

    Commit E

F2
F4

commit e961b6e8eee949fc6f83b89861cafec036686212
Author: ...
Date: ...

    Commit D

F1
F3

commit 78dffe9c5fa346d03458d15862a38325ebb2e896
Author: ...
Date: ...

    Commit C

F1
F2

commit b132b2646c3f6203d27319d6296a1292710f2782
Author: ...
Date: ...

    Commit B

F2
F3

commit 75f789b08f5f7dc11557614fddcb941270829fdc
Author: ...
Date: ...

    Commit A

F1
F2
$ git status

On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   F2

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   F3

Example - Soft Reset to Commit C

After running git reset --soft 78dffe9c5fa346d03458d15862a38325ebb2e896:

$ git status

On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   F1
    modified:   F2
    modified:   F3
    new file:   F4

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   F3

To show that we have the correct F2E change we can run git diff --cached F2 to get the difference between the index and HEAD versions. It shows the inclusion of the 2 lines from both changes, F2D and F2E.

$ git diff --cached F2

diff --git a/F2 b/F2
index a2e06c8..3a24ea5 100644
--- a/F2
+++ b/F2
@@ -1,3 +1,5 @@
 F2A
 F2B
 F2C
+F2D
+F2E

Mixed Reset

For a mixed reset, all changes in the orphaned commits are placed back in the working directory and all currently staged changes are unstaged, i.e., moved back into the working directory. This means the index now matches the HEAD.

💡
The mixed mode is the default mode when performing a reset, and in this case the mode flag can be omitted. The following two commands are equivalent:
git reset --mixed COMMIT_HASH`
git reset COMMIT_HASH

To demonstrate a mixed reset we can use the same setup as before. From the example below, we again start with a series of five commits: A, B, C, D, and E.1 Also, as before, in this state we have changes in the working directory with F3C that haven't been staged, and changes in the index with F2E that haven't been committed.

Performing a git reset --mixed C2 moves the HEAD back to commit C. This causes commits D and E to be orphaned. All changes from the orphaned commits and any staged changes are placed back into the working directory. Similarly to the previous example, there are overlaps between F2D/F2E, and F3B/F3C resulting in the most recent changes being kept, F2E and F3C. One last thing to note is that F4A is now considered an untracked file because from the commit history's perspective it's a new file that has never been staged.

Example of how a mixed reset affects the HEAD, index, and working directory

Example - Setup Commands

mkdir mixed_reset       
cd mixed_reset
git init
touch F1
echo "F1A" >> F1
touch F2
echo "F2A" >> F2
git add .
git commit -m "Commit A"
touch F3
echo "F3A" >> F3
echo "F2B" >> F2 
git add .
git commit -m "Commit B"
echo "F1B" >> F1
echo "F2C" >> F2
git add .
git commit -m "Commit C"
echo "F1C" >> F1
echo "F3B" >> F3
git add .
git commit -m "Commit D"
echo "F2D" >> F2
touch F4
echo "F4A" >> F4
git add .
git commit -m "Commit E"
echo "F2E" >> F2
git add F2
echo "F3C" >> F3

Example - Setup State

$ git log --name-only

commit fc52174c53e4f3af9f7c85be2f5e15edd51a4e3f (HEAD -> main)
Author: ...
Date: ...

    Commit E

F2
F4

commit ef5162ee0dc249bc3df73f6330b9bdab20462623
Author: ...
Date: ...

    Commit D

F1
F3

commit efada051fe9b3ac195184008c2dac4a14b501402
Author: ...
Date: ...

    Commit C

F1
F2

commit 4dbd5152f6ee12fc6f8124667513789f3d9466ed
Author: ...
Date: ...

    Commit B

F2
F3

commit 9e3f1ac4b98ab0791183dfa0f06d9a7620706b21
Author: ...
Date: ...

    Commit A

F1
F2
$ git status

On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   F2

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   F3

Example - Mixed Reset to Commit C

After running git reset --mixed efada051fe9b3ac195184008c2dac4a14b501402:

$ git status

On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   F1
    modified:   F2
    modified:   F3

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    F4

no changes added to commit (use "git add" and/or "git commit -a")

To show that we have the correct F2E and F3C file versions we can just view their contents:

$ cat F2

F2A
F2B
F2C
F2D
F2E

$ cat F3

F3A
F3B
F3C

Hard Reset

A hard reset is the simplest but should be used carefully because it is also destructive. All changes from the index, working tree, and orphaned commits are deleted.

💡
While any uncommitted changes in the working directory or index are lost after a hard reset, it is possible to recover changes from orphaned commits using the reflog. However, that's outside the scope of this article.

From the example below, we again start with a series of five commits: A, B, C, D, and E.1 Also, as before, in this state we have changes in the working directory with F3C that haven't been staged, and changes in the index with F2E that haven't been committed.

Performing a git reset --hard C2 moves the HEAD back to commit C. Changes in the index and working directory are discarded, as well as any changes from the orphaned commits, D and E. This leaves the working directory, index, and head all in sync.

Example of how a hard reset affects the HEAD, index, and working directory

Example - Setup Commands

mkdir hard_reset       
cd hard_reset
git init
touch F1
echo "F1A" >> F1
touch F2
echo "F2A" >> F2
git add .
git commit -m "Commit A"
touch F3
echo "F3A" >> F3
echo "F2B" >> F2 
git add .
git commit -m "Commit B"
echo "F1B" >> F1
echo "F2C" >> F2
git add .
git commit -m "Commit C"
echo "F1C" >> F1
echo "F3B" >> F3
git add .
git commit -m "Commit D"
echo "F2D" >> F2
touch F4
echo "F4A" >> F4
git add .
git commit -m "Commit E"
echo "F2E" >> F2
git add F2
echo "F3C" >> F3

Example - Setup State

$ git log --name-only

commit b379b56b724c2ad9074d84ea2bae38c3394769a7 (HEAD -> main)
Author: ...
Date: ...

    Commit E

F2
F4

commit 82e8bfef9df0e6d53ff6cb21ddf17d4196de340f
Author: ...
Date: ...

    Commit D

F1
F3

commit 967ccf7ce4ec657dcbc9d9985349eda675fb0379
Author: ...
Date: ...

    Commit C

F1
F2

commit f3c30c1d6fc908df990673899264022461914ac6
Author: ...
Date: ...

    Commit B

F2
F3

commit 784afdf9e7d3b7189f457bd5c8cfb6df538eaf15
Author: ...
Date: ...

    Commit A

F1
F2
$ git status

On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   F2

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   F3

Example - Hard Reset to Commit C

After running git reset --hard 967ccf7ce4ec657dcbc9d9985349eda675fb0379:

$ git status

On branch main
nothing to commit, working tree clean

Conclusion

The git reset command is a useful feature of Git that allows us to rewind a branch by moving the HEAD to an earlier commit. When rewinding a branch, we can control what happens to changes in the working directory, index, and any orphaned commits by using the three different reset modes: soft, mixed, and hard.

A soft reset will preserve any changes in the index at the time of reset while also placing any changes from orphaned commits back into the index. The working directory is left completely unchanged.

A mixed reset preserves all current changes in the working directory. Also, all changes from the index and the orphaned commits are moved back into the working directory. The index is left empty and in sync with the HEAD.

Lastly, a hard reset is the simplest because all changes from the working directory, index, and orphaned commits are just deleted.


Thank you for taking the time to read my article. I hope it was helpful.

If you noticed anything in the article that is incorrect or isn't clear, please let me know. I always appreciate the feedback.