Testing in Git (pre-commit)

Nobody likes making mistakes. Even worse is if our mistakes are on display for others to see. To have every little mistake we make recorded and published for all time is certainly not an ideal situation, but that’s often exactly what we do to ourselves when we put our work in version control systems like git!

How often have you had the unenviable choice between altering public history or making awkward revert commits?

How can we avoid such annoyances? Not use git? Perish the thought. One thing we can do is to fix our mistakes before we record them for posterity. We can even have git automatically stop us if we try to make a commit that contains a mistake.

How can such a miracle be possible, you ask? Read on to find out.

Note: I’ll be going through step by step how to create the perfect pre-commit testing hook, but if you’d prefer to skip all of the tl;dr crap and get right to the final version, I won’t blame you.

Disclaimer

I’ve done my best to make sure that everything here is correct and that the final version is as safe and useful as possible. That said, I cannot guarantee that it won’t cause you harm; keep reading at your own risk and make sure you understand exactly what is happening and what the consequences might be at each step before you follow any instructions or advice!

If you notice any errors or have suggestions for improvement, please let me know!

Behold, the Mighty pre-commit Hook

Git hooks are shell scripts you can write that git will call automatically at certain times. If you get lost or the concept or want more information you might want to check out the hooks section in the Pro Git book or ye olde githooks manpage for more information.

The basic idea is that git will run a pre-commit hook (that we’ve previously written) before every commit. If that script indicates that there’s problems, it can abort the commit and let us fix the problem before we continue.

So let’s give this a try and see how it works.

Demo Git Repository

First let’s create a fake git repo for demo purposes and start tracking a file in it:

$ git init pre-commit
Initialized empty Git repository in ~/pre-commit/.git/
$ cd pre-commit/
$ echo "blah" > content_file.fake
$ git add content_file.fake
$ git commit -m "create a fake content file for demo"
[master (root-commit) 1764431] create a fake content file for demo
 1 file changed, 1 insertion(+)
 create mode 100644 content_file.fake

Setting Up the Hook

Next, let’s set up the actual hook and see how this works. I don’t want to assume knowledge of a particular programming language, so we’re going to define an error as simply a file containing the word “error”. Obviously what you test for depends on what type of content you’re tracking, but for demo purposes let’s just run with our easy to test condition.

So, first create the pre-commit.sh script with your favorite editor:

$ gvim pre-commit.sh

and put the following content into it:

#!/bin/sh

if grep --with-filename --line-number error ./*.fake
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

and then let’s link it into the place git expects and make it executable:

$ chmod +x pre-commit.sh
$ ln --symbolic --relative pre-commit.sh .git/hooks/pre-commit

and finally let’s record our script in git as well:

$ git add pre-commit.sh
$ git commit -m "start a pre-commit hook to ensure quality content"
[master 804d279] start a pre-commit hook to ensure quality content
 1 file changed, 9 insertions(+)
 create mode 100755 pre-commit.sh

The reason we link the script using ln --symbolic is so that the actual file itself can be tracked in the git repo. I like to have it available in every clone and to track changes made to it. If you don’t care about that, you can just create the file directly in the .git/hooks/ directory in your repo, but I’d recommend doing it the way I’ve shown.

So what did all of that do? Our pre-commit.sh script will simply check for any lines containing the word “error” in any file ending with the extension .fake. If it finds any, our script will exit with non-zero status code, which git will recognize as a signal to abort the commit.

The pre-commit Script in Action

Let’s see how it works:

$ echo foobar >> content_file.fake
$ git add content_file.fake
$ git commit -m "add some more content"
[master 7db9e78] add some more content
 1 file changed, 1 insertion(+)

So far nothing is different, we added some more content and made a commit.

Now what happens when we try to commit some content with an error?

$ echo "line with error" >> content_file.fake
$ git add content_file.fake
$ git commit -m "add an error"
content_file.fake:3:line with error
Error found, aborting commit

Yay, git noticed that it has an error and aborted the commit!

We can verify that the commit was not created:

$ git log --oneline
7db9e78 add some more content
804d279 start a pre-commit hook to ensure quality content
1764431 create a fake content file for demo
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

Then if we fix the line with an error, we can commit normally:

$ sed --in-place "s/error/baz/" content_file.fake
$ git add content_file.fake
$ git commit -m "commit our good content"
[master 94db3c7] commit our good content
 1 file changed, 1 insertion(+)

So far so good. So we’re all done then, right?

Unfortunately not. Let’s explore why we need to do more work.

Some Problems With the Simple Attempt

$ echo "an error" >> content_file.fake
$ git add content_file.fake
$ git commit -m "commit an error"
content_file.fake:4:an error
Error found, aborting commit

So far this is exactly like before, we make an error, add it to git’s staging area and then try to commit, but the commit is rejected (as it should be).

Now let’s fix the error, but forget to git add our fix and see what happens.

$ sed --in-place "s/error/baz/" content_file.fake
$ git commit -m "commit an error"
[master b2111a6] commit an error
 1 file changed, 1 insertion(+)
$ git log --patch -1
commit b2111a68e5324b55a4a51d6cc805449a34125e50
Author: Joshua Simmons <joshua.simmons@emptypath.com>
Date:   Thu Mar 12 18:18:52 2015 -0700

    commit an error

diff --git a/content_file.fake b/content_file.fake
index 61c95a3..ed42949 100644
--- a/content_file.fake
+++ b/content_file.fake
@@ -1,3 +1,4 @@
 blah
 foobar
 line with baz
+an error

Well shit, why did that happen? Shouldn’t git have prevented us from commiting the line with an error? As it turns out, git just runs the pre-commit hook in the usual working directory of our repo right before it commits. It doesn’t run our checks on exactly what will be commited, it runs them on the current state of the working directory!

So that won’t really work very well in practice unless you religiously use

$ git commit -a

or something similar which isn’t very flexible or fun.

Thankfully there’s no need to give up the usefulness that is git’s staging area in order to accomplish what we want.

Note: There may be other problems with the solution above even if you always use git commit -a, I’ve never used it in practice and I stopped exploring it once I realized it wouldn’t work for me.

Making the Working Directory Match the Staging Area

To fix the problem above, we’ll have to work out some way of making the working directory of our git repo look exactly like what is about to be commited before we run our tests. We also need a way to undo our changes and put everything back in place; it wouldn’t be very nice to throw away every unstaged change every time we commit.

git clean, git reset and git checkout could be used to get out working directory looking like our staging area, but we’d have little hope of restoring from our changes just using those.

So what can we do? Well, git stash should be useful, it does something close to what we want, and thankfully it has some command-line switches that will make it exact, with a little extra work. It may be helpful to have a link to the git stash manpage handy, it’d be a waste of time for me to try to replicate everything it says about the options I’ll be using.

To reiterate: I’m going pretty close to step-by-step of how you could work this out for yourself. If you get bored, you can skip to the final version.

Now, in case you’re following along on your own terminal: before we go on, let’s clean up from our last test by commiting the fix to the error we added.

$ git status
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:   content_file.fake

no changes added to commit (use "git add" and/or "git commit -a")
$ git diff
diff --git a/content_file.fake b/content_file.fake
index ed42949..082cd22 100644
--- a/content_file.fake
+++ b/content_file.fake
@@ -1,4 +1,4 @@
 blah
 foobar
 line with baz
-an error
+an baz
$ git commit -a -m "fix error"
[master 5125858] fix error
 1 file changed, 1 insertion(+), 1 deletion(-)

Now we’re back at a clean working directory, let’s edit our pre-commit.sh and try to improve it.

$ gvim pre-commit.sh

Put in the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh

git stash save --keep-index --include-untracked

grep --with-filename --line-number error ./*.fake
TEST_RESULT=$?

git stash pop

if [ "$TEST_RESULT" -eq 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

The main difference in this version is lines 3 and 8. We save changes from the working directory into a stash (line 3), run our test saving the result for later (lines 5 and 6), and then pop the stash to restore changes (line 8) before we check the result of testing.

Trying Out the Stashing Version

Now let’s commit the change to our pre-commit.sh and try it out:

1
2
3
4
5
6
7
8
9
10
11
12
$ git commit -a -m "stash changes before testing"
Saved working directory and index state WIP on master: 5125858 fix error
HEAD is now at 5125858 fix error
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   pre-commit.sh

Dropped refs/stash@{0} (2a44422937aec2f3343c56260faaa1fd89cccb81)
[master c64d8e9] stash changes before testing
 1 file changed, 8 insertions(+), 1 deletion(-)

Note that there is some gunk output when we commit now (lines 2-10), caused by our git stash commands. We’ll quiet that down later via a command-line-switch to stash, but for now it’ll be useful debugging information occasionally, so we’ll have to live with it for a bit.

And now we can try it out. Let’s run the same test we did before that wasn’t handled correctly. We’ll make an error, stage it, fix the error but forget to stage the fix, and then try to commit and see what happens.

$ echo "another error" >> content_file.fake
$ git add content_file.fake
$ git commit -m "commit an error"
Saved working directory and index state WIP on master: c64d8e9 [...]
HEAD is now at c64d8e9 stash changes before testing
content_file.fake:5:another error
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

Dropped refs/stash@{0} (82dcd10576a114d0adeaf96f572b4305f05173f4)
Error found, aborting commit

So far so good, it stopped us from commiting an error. Now let’s fix the error, not stage the fix, and see if it still gets caught.

1
2
3
4
5
6
7
8
$ sed --in-place "s/error/quux/" content_file.fake
$ git commit -m "commit an error"
Saved working directory and index state WIP on master: c64d8e9 [...]
HEAD is now at c64d8e9 stash changes before testing
content_file.fake:5:another error
Auto-merging content_file.fake
CONFLICT (content): Merge conflict in content_file.fake
Error found, aborting commit

Uh oh. Line 8 looks good, it aborted the commit like we hoped, but what’s with lines 6 and 7? Merges? Conflicts? Uh, what?!

The problem is, when git stash pop goes to apply the changes back into the working directory, it sees the change we still have in the staging area as a separate thing and thinks it needs to merge the two together. Since that can’t be done automatically (they’re two different additions to the same line), git gives up and says there’s a conflict that we have to fix.

Instead, we want git to first roll back the state to exactly what was there when we stashed, not try to apply the stash on top of our staged changes.

So let’s abandon our testing change first, and then we’ll fix up the hook.

$ git checkout HEAD content_file.fake

There we go, now the conflict and our attempted changes to the content_file.fake are thrown out.

Open up our hook again:

$ gvim pre-commit.sh

and change it to match this (line 8 is the only change):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh

git stash save --keep-index --include-untracked

grep --with-filename --line-number error ./*.fake
TEST_RESULT=$?

git reset --hard && git stash pop --index

if [ "$TEST_RESULT" -eq 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

What this does differently is it resets our working directory and staging area to a pristine state, and then applies the stash which will reinstate exactly the changes we had before, without the possibility of a conflict.

Make sure you save your changes to the hook, and then let’s commit it and we’ll try testing again.

$ git commit -a -m "apply stash to pristine state"
Saved working directory and index state WIP on master: c64d8e9 [...]
HEAD is now at c64d8e9 stash changes before testing
HEAD is now at c64d8e9 stash changes before testing
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   pre-commit.sh

Dropped refs/stash@{0} (61267e7081e859ce239846f456d30bc24fb8797c)
[master 12ffc65] apply stash to pristine state
 1 file changed, 1 insertion(+), 1 deletion(-)

Now another test. We add an error, try to commit it, and it’s rejected:

$ echo "yet another error" >> content_file.fake
$ git add content_file.fake
$ git commit -m "commit an error yet again"
Saved working directory and index state WIP on master: 12ffc65 [...]
HEAD is now at 12ffc65 apply stash to pristine state
content_file.fake:5:yet another error
HEAD is now at 12ffc65 apply stash to pristine state
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

Dropped refs/stash@{0} (53f92525686104c8ed48f20dbae6014ac80970fe)
Error found, aborting commit

So far so good.

Then we fix the error, fail to stage the fix and try to commit it, and it’s rejected (as it should be):

$ sed --in-place "s/error/quux/" content_file.fake
$ git commit -m "commit an error yet again"
Saved working directory and index state WIP on master: 12ffc65 [...]
HEAD is now at 12ffc65 apply stash to pristine state
content_file.fake:5:yet another error
HEAD is now at 12ffc65 apply stash to pristine state
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

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:   content_file.fake

Dropped refs/stash@{0} (c8d17abc74360db90ed028a9b8005c721a2aca2b)
Error found, aborting commit

We should make sure the state is what we expect now, since we added that reset logic to the hook, so let’s see:

$ git diff
diff --git a/content_file.fake b/content_file.fake
index 2e76906..35c48a4 100644
--- a/content_file.fake
+++ b/content_file.fake
@@ -2,4 +2,4 @@ blah
 foobar
 line with baz
 an baz
-yet another error
+yet another quux
$ git diff --cached
diff --git a/content_file.fake b/content_file.fake
index 082cd22..2e76906 100644
--- a/content_file.fake
+++ b/content_file.fake
@@ -2,3 +2,4 @@ blah
 foobar
 line with baz
 an baz
+yet another error

Looks good, the error line is staged and the fix for the error line is there but unstaged, exactly as we were expecting.

Now let’s make sure the actual commit works once we stage the fix:

$ git add content_file.fake
$ git commit -m "commit our error-free line"
Saved working directory and index state WIP on master: 12ffc65 [...]
HEAD is now at 12ffc65 apply stash to pristine state
HEAD is now at 12ffc65 apply stash to pristine state
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

Dropped refs/stash@{0} (d0f77aedad57592a7953aefa2c9ff764c86b7f8e)
[master 6dd208d] commit our error-free line
 1 file changed, 1 insertion(+)

We fixed, we staged, we commited, it worked! Hot damn, we’re actually getting somewhere.

We’re actually pretty close to the final version now, but there’s still a couple of edge cases to handle.

Commits With No Changes

To show this issue, we first have to set up a stash:

$ echo "stash me" >> content_file.fake
$ git stash
Saved working directory and index state WIP on master: 8174255 [...]
HEAD is now at 8174255 commit our error-free line
$ git status
On branch master
nothing to commit, working directory clean

Great, so we have a stash. Now we can see the problem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ git commit --amend -m "changed commit message"
No local changes to save
HEAD is now at 8174255 commit our error-free line
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:   content_file.fake

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (9620b77384357b15e8013cd111d0a1d2eb456e99)
[master 78e86ff] changed commit message
 Date: Fri Mar 13 15:43:18 2015 -0700
 1 file changed, 1 insertion(+)
$ git diff
diff --git a/content_file.fake b/content_file.fake
index 35c48a4..a98a5bd 100644
--- a/content_file.fake
+++ b/content_file.fake
@@ -3,3 +3,4 @@ foobar
 line with baz
 an baz
 yet another quux
+stash me

Of particular note are lines 2 and 16-25. Line 2 is the output from the git stash save line in our hook. It’s saying that there’s no changes, so it’s not bothering to make a stash.

That’s fine I guess, but then note that after our commit, the unrelated stash we made above has been applied! That’s certainly not what we wanted to happen.

This fix for this issue is to make our hook recognize when there’s no changes and to stop running before we apply a stash (since the hook didn’t create a stash).

So, let’s stash that change that was stashed to begin with, and then we’ll edit the hook.

$ git stash
Saved working directory and index state WIP on master: 78e86ff [...]
HEAD is now at 78e86ff changed commit message

Then change the hook file to match this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/sh

BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
git stash save --keep-index --include-untracked
AFTER_STASH=$(git rev-parse --verify -q refs/stash)

if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
then
    echo "pre-commit: nothing to test"
    sleep 1
    exit 0
fi

grep --with-filename --line-number error ./*.fake
TEST_RESULT=$?

git reset --hard && git stash pop --index

if [ "$TEST_RESULT" -eq 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

We’ve added line 3 and lines 5-12. What this does is: checks what the current stash is, then runs git stash save, then checks again what the current stash is.

If the current stash didn’t change, then there weren’t any changes to stash and we can quit there before we do any more work.

Note: you can experiment with git rev-parse --verify on your own, but essentially all it does is output the SHA1-hash of the ref you ask for. If there’s no stash, it’ll output nothing. That does exactly what we need, in context.

So, once you save the changes to pre-commit.sh, let’s commit them:

$ git add pre-commit.sh
$ git commit -m "stop if there's no changes"
Saved working directory and index state WIP on master: 78e86ff [...]
HEAD is now at 78e86ff changed commit message
HEAD is now at 78e86ff changed commit message
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   pre-commit.sh

Dropped refs/stash@{0} (22174e5cfd3279eae04eb0329b1315d04a4434c3)
[master 7db17ed] stop if there's no changes
 1 file changed, 9 insertions(+)

And then we can try our test again and make sure we’ve fixed the problem:

$ git commit --amend -m "changed commit message"
No local changes to save
pre-commit: nothing to test
[master a91f9fb] changed commit message
 Date: Fri Mar 13 16:32:03 2015 -0700
 1 file changed, 9 insertions(+)
$ git status
On branch master
nothing to commit, working directory clean
$ git stash list
stash@{0}: WIP on master: 78e86ff changed commit message

Great! The commit went through, our “nothing to test” message got printed, and our pre-existing stash is still there as we expected.

So that’s one problem down, on to the next one.

Changing Tests

Let’s say we’re thinking about changing what our tests consider failing. We’re tired of “error” being the word that causes a failure, we’d like “wrong” to be the new word.

Let’s edit our pre-commit.sh and change that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/sh

BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
git stash save --keep-index --include-untracked
AFTER_STASH=$(git rev-parse --verify -q refs/stash)

if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
then
    echo "pre-commit: nothing to test"
    sleep 1
    exit 0
fi

grep --with-filename --line-number wrong ./*.fake
TEST_RESULT=$?

git reset --hard && git stash pop --index

if [ "$TEST_RESULT" -eq 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

Line 14 is the only part changed, we changed “error” to “wrong”.

Make sure you save your changes, but don’t stage them yet.

Then lets say we get called away and forget what we’re doing. When we come back, we decide to make a change to our content. Since we’re in a rush, it has some errors in it:

$ echo "error error error" >> content_file.fake
$ git add content_file.fake

So will our tests catch that error? Obviously not:

$ git commit -m "we love errors"
Saved working directory and index state WIP on master: a91f9fb [...]
HEAD is now at a91f9fb changed commit message
HEAD is now at a91f9fb changed commit message
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

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:   pre-commit.sh

Dropped refs/stash@{0} (fea441cb3a12cc50cb878f6514f382fe9a1dfc14)
[master 544ada4] we love errors
 1 file changed, 1 insertion(+)

So the commit went through. That’s pretty expected, but it’s also not really ideal. The version of the testing script that is in history doesn’t match the code that’s in history now. In other words, if you just look at the commit history, it looks like the code should be failing tests, but it’s not because we changed the hook script but didn’t commit that change.

How can we prevent that from happening? We can’t completely, but we can make it much less likely to happen.

What you can do is move everything that’s likely to change, which in our lame example tests is the grep line, into a different file. It’s really that easy.

The reason that helps is, the tests that will change reasonably often will be in a separate file, so when our stash and unstash code is running, they won’t be in the same script that can’t change because it’s already executing; all of our logic will apply to the tests just as it applies to all other content.

First of all, let’s get rid of the gunk left over from our previous test.

$ git reset --hard HEAD~1
HEAD is now at a91f9fb changed commit message
$ git status
On branch master
nothing to commit, working directory clean

Now here’s what we’ll do:

Create a new file, named auto_tests.sh. We’re going to put the actual tests we want to run in there. Its entire job is to return a non-zero status code if the tests fail.

$ gvim auto_tests.sh

Put the following content into the new file:

#!/bin/sh

grep --with-filename --line-number error ./*.fake

if [ "$?" -eq 0 ]
then
    exit 1
fi

exit 0

We’re just running the same test as ever, then we check the result and if grep found an “error” we return non-zero, since that’s our failing case.

Make sure you save your changes, and then we’ll set it executable:

$ chmod +x auto_tests.sh

Now we have to edit pre-commit.sh and change it to call that script we just created. Open pre-commit.sh and make it look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/sh

BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
git stash save --keep-index --include-untracked
AFTER_STASH=$(git rev-parse --verify -q refs/stash)

if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
then
    echo "pre-commit: nothing to test"
    sleep 1
    exit 0
fi

./auto_tests.sh
TEST_RESULT=$?

git reset --hard && git stash pop --index

if [ "$TEST_RESULT" -ne 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

We’ve changed line 14 and line 19. We’re calling our new auto_tests.sh script now, and line 19 needed to change because the failing condition is different. auto_tests.sh exits with non-zero status if there’s a failure, so that’s what we need to test for on line 19 (so the -eq changed to a -ne).

That all looks good, so let’s commit it and then we’ll try it out.

$ git add pre-commit.sh auto_tests.sh
$ git commit -m "run the staged tests on the staged content"
Saved working directory and index state WIP on master: a91f9fb [...]
HEAD is now at a91f9fb changed commit message
HEAD is now at a91f9fb changed commit message
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   auto_tests.sh
        modified:   pre-commit.sh

Dropped refs/stash@{0} (f4515d97a4b8609bc065c916b0a4025cdbc4258f)
[master 8134523] run the staged tests on the staged content
 2 files changed, 12 insertions(+), 2 deletions(-)
 create mode 100755 auto_tests.sh

Now let’s try the same thing we did before. We’ll change the testing script, but not stage that change. Then we’ll add some content and make sure it’s using the git history’s version of the testing script, not our unstaged one.

So edit auto_tests.sh and change line 3 line like so:

1
2
3
4
5
6
7
8
9
10
#!/bin/sh

grep --with-filename --line-number wrong ./*.fake

if [ "$?" -eq 0 ]
then
    exit 1
fi

exit 0

(“error” became “wrong”)

Save the change, but don’t stage it.

Then let’s modify our content again, stage that part and then try to commit:

$ echo "error error error" >> content_file.fake
$ git add content_file.fake
$ git commit -m "should fail"
Saved working directory and index state WIP on master: 8134523 [...]
HEAD is now at 8134523 run the staged tests on the staged content
content_file.fake:6:error error error
HEAD is now at 8134523 run the staged tests on the staged content
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   content_file.fake

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:   auto_tests.sh

Dropped refs/stash@{0} (9a55c96ab97b5e3d21c99f7b6edccc2fa32ccadc)
Error found, aborting commit

Huzzah! It’s now using the staged version of the tests against the staged content, exactly as it should.

Now history will always having a matching pair of testing script and content.

So are we done yet? Just about. We just have a few little fixups to do, and then we have just one more pretty obscure case to handle. To start with, edit pre-commit.sh and change it to match this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/sh

# redirect output to stderr
exec 1>&2

BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
git stash save --keep-index --include-untracked -q
AFTER_STASH=$(git rev-parse --verify -q refs/stash)

if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
then
    echo "pre-commit: nothing to test"
    sleep 1
    exit 0
fi

./auto_tests.sh
TEST_RESULT=$?

git reset --hard -q && git stash pop -q --index

if [ "$TEST_RESULT" -ne 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

We added lines 3-5 to redirect any output to stderr, which is where diagnostics and errors should generally go.

We changed line 7 and line 20 to add -q flags to make the stash and reset commands silent, since otherwise it makes commiting a very verbose process.

Save your changes, undo our previous testing gunk, commit our new pre-commit.sh, and then we’ll take care of our last issue.

$ git checkout HEAD -- content_file.fake
$ git checkout HEAD -- auto_tests.sh
$ git add pre-commit.sh
$ git commit -m "output to stderr and quiet down"
[master d5f4776] output to stderr and quiet down
 1 file changed, 5 insertions(+), 2 deletions(-)

Orphan Branches

In git, an orphan branch is one that starts with a blank slate. The first commit in it won’t have a parent.

Quite a few things don’t work very well at the start of an orphan branch (before you’ve commited anything at all to the branch) including some of the stuff we’re using. In particular, stashing doesn’t work at all, which breaks some of our assumptions, so let’s find a way to handle that.

By the way, there’s two major ways to get into an orphan branch state: it’s the state you’re in right after you git init before you make a commit, or you can manually run:

$ git checkout --orphan new_branch
Switched to a new branch 'new_branch'
$ git status
On branch new_branch

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   auto_tests.sh
        new file:   content_file.fake
        new file:   pre-commit.sh

If you’re still following along at home, let’s run the above checkout command now and we’ll see how our script handles.

Notice that git copied over our content, which is our pre-commit hook and our testing script. It does this for convenience so you can choose what content to keep in the new branch. The commits from our old master branch have nothing at all to do with our new branch though. We’re currently not on a commit at all, as you can see if you run:

$ git rev-parse --verify HEAD
fatal: Needed a single revision

That error message is just git’s friendly way of saying that HEAD doesn’t mean anything currently; it doesn’t resolve to a commit at all.

For kicks, let’s see what happens when we try to stash:

$ git stash save
fatal: bad revision 'HEAD'
fatal: bad revision 'HEAD'
fatal: Needed a single revision
You do not have the initial commit yet
$ git status
On branch new_branch

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   auto_tests.sh
        new file:   content_file.fake
        new file:   pre-commit.sh

So, we get a pretty bizarre error, and it otherwise doesn’t do anything. Okay then.

Now let’s see what happens when we commit our testing script and content:

$ git commit -m "initial commit on new_branch"
fatal: bad revision 'HEAD'
fatal: bad revision 'HEAD'
fatal: Needed a single revision
You do not have the initial commit yet
pre-commit: nothing to test
[new_branch (root-commit) 1d08868] initial commit on new_branch
 3 files changed, 43 insertions(+)
 create mode 100755 auto_tests.sh
 create mode 100644 content_file.fake
 create mode 100755 pre-commit.sh

So we get those fun bizarre error messages, our tests aren’t being run, but the commit goes through.

It’s not the end of the world, but we should probably handle this case anyway.

It’d be nice if our tests can actually be run at least, and we should quiet down that error since it sounds pretty bad.

So alright, let’s switch back to our master branch and then we’ll make a fix.

$ git checkout master
Switched to branch 'master'

Then, open up pre-commit.sh again and change it to look like this:

#!/bin/sh

# redirect output to stderr
exec 1>&2

# if we're not on an orphan branch
if git rev-parse --verify -q HEAD > /dev/null
then
    BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
    git stash save --keep-index --include-untracked -q
    AFTER_STASH=$(git rev-parse --verify -q refs/stash)

    if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
    then
        echo "pre-commit: nothing to test"
        sleep 1
        exit 0
    fi

    ORPHAN=0
else
    ORPHAN=1
    echo "pre-commit WARNING:"
    echo "Be extra careful that you've staged all changes."
    echo "On a newly created orphan branch, tests will run on"
    echo "everything in the working directory, not on just the"
    echo "content to be commited as is usually done!"
    sleep 5
fi

./auto_tests.sh
TEST_RESULT=$?

if [ "$ORPHAN" -eq 0 ]
then
    git reset --hard -q && git stash pop -q --index
fi

if [ "$TEST_RESULT" -ne 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

There are quite a few added/changed lines there, but conceptually it wasn’t a very big change.

We test for the case that we’re at the start of an orphan branch, if we are, we don’t bother stashing or unstashing, because that won’t work anyway (stash just doesn’t work before you have a commit on an orphan branch). We output a warning so we know to be extra careful, and then we just run the tests.

Now let’s save our changes, commit the result and we’ll try it out to make sure it works correctly.

$ git add pre-commit.sh
$ git commit -m "handle start of orphan branches better"
[master 8b6adb8] handle start of orphan branches better
 1 file changed, 45 insertions(+), 28 deletions(-)
 rewrite pre-commit.sh (61%)

So let’s test it out, we’ll start a new orphan branch and make sure everything works correctly.

$ git checkout --orphan orphan_test
Switched to a new branch 'orphan_test'
$ git status
On branch orphan_test

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   auto_tests.sh
        new file:   content_file.fake
        new file:   pre-commit.sh

$ git commit -m "initial commit"
pre-commit WARNING:
Be extra careful that you've staged all changes.
On a newly created orphan branch, tests will run on
everything in the working directory, not on just the
content to be commited as is usually done!
[orphan_test (root-commit) ea468dd] initial commit
 3 files changed, 60 insertions(+)
 create mode 100755 auto_tests.sh
 create mode 100644 content_file.fake
 create mode 100755 pre-commit.sh

Great, that looks good. We got our custom warning, but otherwise no weird errors and everything looks fine!

Let’s try one more test, just to make sure it’s actually running our auto_tests.sh script:

$ git checkout --orphan another_orphan
Switched to a new branch 'another_orphan'
$ echo "error line" >> content_file.fake
$ git add content_file.fake
$ git commit -m "initial commit with error"
pre-commit WARNING:
Be extra careful that you've staged all changes.
On a newly created orphan branch, tests will run on
everything in the working directory, not on just the
content to be commited as is usually done!
./content_file.fake:6:error line
Error found, aborting commit

Excellent, everything is working!

Read on for the easy installation instructions and some further advice.

Even if you’ve been following along, I would recommend that you don’t use the exact version we’ve derived above; the final version below has a explicit license, is easier to install and use, and is a bit better documented.

Also note that grep, as we’ve been using it above, has an opposite resulting status code from what the final version below expects, so the test in auto_tests.sh will be -ne instead of -eq.

Final Version and Installation

This is the easiest way to install the pre-commit hook:

$ git remote add pre-commit https://www.emptypath.com/pre-commit.git
$ git fetch pre-commit
From https://www.emptypath.com/pre-commit
 * [new branch]      pre-commit -> pre-commit/pre-commit
$ git merge -m "add pre-commit hook" pre-commit/pre-commit
$ ./hook/install_hook.sh
pre-commit hook installed successfully.

To be clear, the above shows my terminal prompts (the “$”), what I typed and what the output should be. If you want an easy to copy-and-paste version, here’s just the commands:

git remote add pre-commit https://www.emptypath.com/pre-commit.git
git fetch pre-commit
git merge -m "add pre-commit hook" pre-commit/pre-commit
./hook/install_hook.sh

All that does is add my pre-commit.git repository as a remote, fetch in the hook and associated helpers, and then installs the hook by linking it into .git in the correct place.

That will work perfectly on an existing repo with lots of content, or a new repo that you’ve just created.

Now the hook is set up to run. The only thing you have to do is edit hook/auto_tests.sh, specifically the lines it tells you to edit.

You should change the echo lines in that file to be a single command that runs in the root of your working directory and will exit with non-zero status if there’s a problem with the commit.

If you have multiple testing commands, you can chain them together with &&, or follow each one with a copy of the same if block that I have after the echo commands.

When you’ve put in your testing command, commit your changes and you’re all set.

Now, every time you commit, your changes will be automatically checked against the testing script. Any failing test will abort the commit so you can fix it. It will correctly handle: all uses of the staging area; modification of tests; merges, rebases and cherry-picks; amended commits; orphan branches; and every other git command or situation I’ve thrown at it.

The exact content that is-to-be-commited will be tested with the tests as-they-are-to-be-commited. This means the tests will match the content in history.

For completeness, here is the contents of the files that will be created by the above instructions:

Important: If, for whatever reason, you manually copy these in, make sure you either put them in a hook directory in the top level of your repo, or edit the scripts appropriately. If you don’t know what that means, follow the easy instructions above instead.

hook/pre-commit.sh:

#!/bin/sh

# Copyright Joshua Simmons
#
# https://www.emptypath.com/git_pre_commit_testing
#
# Available under the BSD3 license, which can be found in the
# LICENSE file in the same directory as this file.

# You usually should not have to make any changes to this file!
#
# Add or change tests in the `auto_tests.sh` file instead.

# redirect output to stderr
exec 1>&2

# if we're not on an orphan branch
if git rev-parse --verify -q HEAD > /dev/null
then
    BEFORE_STASH=$(git rev-parse --verify -q refs/stash)
    git stash save --keep-index --include-untracked -q
    AFTER_STASH=$(git rev-parse --verify -q refs/stash)

    if [ "$BEFORE_STASH" = "$AFTER_STASH" ]
    then
        echo "pre-commit: nothing to test"
        sleep 1
        exit 0
    fi

    ORPHAN=0
else
    ORPHAN=1
    echo "pre-commit WARNING:"
    echo "Be extra careful that you've staged all changes."
    echo "On a newly created orphan branch, tests will run on"
    echo "everything in the working directory, not on just the"
    echo "content to be commited as is usually done!"
    sleep 5
fi

./hook/auto_tests.sh
TEST_RESULT=$?

if [ "$ORPHAN" -eq 0 ]
then
    git reset --hard -q && git stash pop -q --index
fi

if [ "$TEST_RESULT" -ne 0 ]
then
    echo "Error found, aborting commit"
    exit 1
fi

exit 0

hook/auto_tests.sh:

#!/bin/sh

# Copyright Joshua Simmons
#
# https://www.emptypath.com/git_pre_commit_testing
#
# Available under the BSD3 license, which can be found in the
# LICENSE file in the same directory as this file.

# Change the following commands to run your
# personalized tests.
#
# It should return non-zero if an error is found.
# What exactly an error is depends on your content
# and your judgement.
#
# If you have multiple testing commands to run,
# you can either chain them together with &&
# or you can add them below the `if` block
# and *copy* the `if` block below your new command.
echo "WARNING: Tests not fully set up!" &&
echo "edit auto_tests.sh to finish setup"

if [ "$?" -ne 0 ]
then
    exit 1
fi

exit 0

hook/install_hook.sh:

#!/bin/sh

# Copyright Joshua Simmons
#
# https://www.emptypath.com/git_pre_commit_testing
#
# Available under the BSD3 license, which can be found in the
# LICENSE file in the same directory as this file.

WORK=$(git rev-parse --show-toplevel) &&
GIT=$(git rev-parse --git-dir) &&
ln --symbolic --relative "$WORK"/hook/pre-commit.sh \
                         "$GIT"/hooks/pre-commit

if [ "$?" -ne 0 ]
then
    echo "Error: could not install pre-commit hook."
    exit 1
else
    echo "pre-commit hook installed successfully."
fi

hook/LICENSE:

Note: The following license only applies to the final version of the previous three files, hook/pre-commit.sh, hook/auto_tests.sh and hook/install_hook.sh. I reserve all rights and give no license for all other content on this page.

Copyright (c) 2015, Joshua Simmons
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name Joshua Simmons nor the
      names of other contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Share and Enjoy!