A Problem with Git Stash

Before going any further I probably need to explain what pre-commit hooks are. So Git has a facility called hooks which allow you to specify a script to execute or a command to run when some event happens. Pre-commit hooks are hooks that run before, you guessed it, a commit. There exists a single file called pre-commit in the .git/hooks directory in your git repository which will run as a script before every commit. If the script exits with an exit status of 0 then the commit operation will proceed. if it is a non zero value, the commit will fail. This is useful for things like running your tests and making sure they pass before you commit (because you don’t want to commit broken code).

But this system is not perfect. Let us consider the situation that we run the test suite as part of the pre-commit. We know that before we commit we need to stage the changes.

$ cd your_repo
$ touch file1.txt file2.txt
$ git add . # staging your changes (in this case, our two new files)
$ git commit -m "commiting a new file1 and file2"

But suppose we stage some changes and then add some new changes that we don’t stage (because it is incomplete or broken or because we want these new changes as a separate commit or some other reason). When the test suite runs, it sees and tests against these new changes.

$ echo "some stuff" >> file1.txt
$ git add . # we only want to run tests on whatever is staged at this point
$ echo "more stuff" >> file2.txt # we don't want this stuff to be tested
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   file1.txt

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

	modified:   file2.txt

#running your tests will test the "more stuff" too
$ test

We know that when the pre-commit tests run, we don’t want the new, unstaged changes to be considered during testing (because we aren’t going to commit those). So what do we do?

One possiblity is git stash --keep-index. Running this will safely store away the unstaged changes and revert everything except the staged changes back to the last commit. The staged changes are retained (running git status will show you that the unstaged changes have disappeared but the staged changes are still there). Running git stash pop will bring back the unstaged changes and restore them.

# repeating the previous example
$ echo "some stuff" >> file1.txt
$ git add . # we only want to run tests on whatever is staged at this point
$ echo "more stuff" >> file2.txt
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   file1.txt

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

	modified:   file2.txt

$ git stash --keep-index
Saved working directory and index state WIP on master: abf9fdb commiting a new file1 and file2
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   file1.txt

$ test # will only test up to your staged changes
$ git commit -m "commiting changes in file1"
[master c553115] commiting changes in file1
 1 file changed, 1 insertion(+)
$ git stash pop # will bring the file2 changes 
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   file2.txt

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (8e82bd143e0e3b7101e5df538201bc6cf5a39640)

It’s a pretty useful mechanic when you need it, especially in our situation. After running git stash --keep-index, we will only have the staged changes and so we can safely run the tests against these changes that we intend to commit. Once commited, git stash pop to restore the unstaged changes from before (though beware of doing this when you have staged and unstaged changes in the same file. git stash pop will create merge conflicts in that case).

But I’ve been running into a problem that I have so far been unable to solve. Consider this: you create some file, stage it, precommit runs the tests and commit finishes successfully. Time passes and now you no longer need the file and so you delete it. You stage the deletion but you also create some other changes that you don’t stage because you want them in a separate commit. Okay, fair enough. We can git stash --keep-index to stash the changes while keeping the staged deletion, run our precommit, commit the deletion, then git stash pop to restore the unstaged changes, right? But, lo and behold, the deleted file has reappeared when you ran git stash --keep-index. When you check git status, the staged deletion is present but a new, untracked file has appeared: the very file that you deleted and staged the deletion of. Now when you try to commit, the precommit tests will also be testing this undeleted file (which you don’t want to do because you expected that file to have been deleted).

# many commits later. We delete file1
$ rm file1.txt
$ git add . # file1 deletion is staged
$ echo "even more stuff" >> file2.txt # changes in file2 that you don't want to stage for this commit
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	deleted:    file1.txt

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

	modified:   file2.txt

$ git stash --keep-index
Saved working directory and index state WIP on master: df05326 your last commit
$ git status # file1 reappears as an untracked file
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	deleted:    file1.txt

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

	file1.txt

$ test # this will be testing file1 also because it has reappeared

This happens because git stash --keep-index restores git repo’s directory to its last commit i.e. the commit where the deleted file was still present. And since we use the --keep-index flag, the staged changes (i.e. our deletion) is not stored in the stash, so git stash pop won’t redelete the undeleted file (because git doesn’t store the deletion information that we have staged into the stash so popping the stash doesn’t effect any deletion operation).

Unfortunately, I am unable to find a workaround for this particular problem. The only advice I can give is to avoid running into this situation in the first place. Just be careful when using git stash and avoid using it when you want to commit file deletions.