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.