Post

Unity and Convenience of Git Hooks

Diving into a large project with a horde of developers without a solid workflow is like trying to herd cats. Entertaining but not particularly productive. But even the best workflows are affected by human factors. People make mistakes, forget things, and sometimes just don’t care. That’s why having a plan B is pretty darn handy.

Once upon a time, on a relatively large project, we constantly had an issue with accidental commits that messed with our project settings. It was like deja vu, having to reverse these changes and then play the nagging parent, explaining why it’s a no-no. Over and over again. That’s when I had my lightbulb moment: why not stop these commits before they even happen?

From those times, I’m a big fan of Git Hooks. So, let me share how they helped us in this particular case. And how to handle them conveniently in Unity.

What are Git Hooks

Git hooks are scripts that Git executes before or after events such as: commit, push, and receive. They are stored in the .git/hooks directory of your project. You can use them to automate tasks, enforce best practices, and prevent bad commits.

Worth to mention that there are two types of hooks: client-side and server-side. Client-side hooks are triggered on your local machine, and server-side hooks are triggered on the remote repository. In this article, we’ll focus on client-side hooks.

How to Use Git Hooks

Git hooks are just regular scripts. The only requirement is that they should be executable. Picking a language for a Git hook is like choosing ice cream flavors – there’s no wrong choice. But I went with Bash, because sometimes, classic vanilla is just what you need. It’s simple, powerful, and available on all platforms. If you have Git installed, you already have Bash.

There are a lot of client-side hooks. You can find the complete list here. In this article, we’ll focus on the pre-commit hook. It’s triggered before each commit and is a perfect place to check if the commit is valid. Exactly what we need to prevent accidental file modifications! However, you can extend our approach to any other hook if you need to.

So, let’s create a pre-commit hook. To do that, we must create a file named exactly as the hook we want to use. With no extension.

1
#!/bin/sh

Then, we need to put it in the .git/hooks directory of our project. And make it executable. That’s it! Now, each time you commit something, Git will execute this script.

How to Check if the Commit is Valid

Now, our hook formally works. It’s triggered before each commit, but it doesn’t do anything. Let’s change that and prevent any commit that modifies the Unity project settings file.

At first, let’s verify that our HEAD exists. It’s a reference to the last commit in the current branch. If it doesn’t exist, it means that we’re committing for the first time. In this case, we will use a special empty tree object as a reference. It’s always there, so we can use it as a reference for the initial commit. We’ll need this reference later.

1
2
3
4
5
6
7
if git rev-parse --verify HEAD >/dev/null 2>&1
then
  against=HEAD
else
  # Initial commit: diff against an empty tree object
  against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi

Then, let’s redirect the output to stderr. It’s not required, but it’s a good practice. It will help you to debug your hooks if something goes wrong.

1
exec 1>&2

Now, we can check if the commit contains changes to the files that must not change. To do that, we’ll use the git diff command. It compares two commits and outputs the list of files that were changed. We’ll use our against variable as the first commit and the --cached flag to compare it with the staged changes.

Then, we’ll pipe the output to the grep command. It will search for the files that must not change. If it finds any, it will output them to stdout. If it doesn’t find any, it will output nothing. Then, we’ll use the --quiet flag to suppress the output. And --line-regexp and --fixed-strings flags to ensure we’re searching for the exact file names.

Finally, we’ll use the --file flag to pass a file to search for.

And the exit command to exit the script with the appropriate exit code.

1
2
3
4
5
6
7
8
if git diff --cached --name-only $against |
   grep --quiet --line-regexp --fixed-strings "ProjectSettings/ProjectSettings.asset"
then
  echo Commit would modify one or more files that must not change.
  exit 1
else
  exit 0
fi

If needed, we could improve that snippet by checking for a list of files at once. Let’s introduce the MUST_NOT_CHANGE variable for those purposes. Paths should be relative to the repo root and separated by newlines.

1
2
3
4
5
6
7
8
9
10
11
12
13
MUST_NOT_CHANGE='
ProjectSettings/ProjectSettings.asset
Assets/Resources/I2Languages.asset
'

if git diff --cached --name-only $against |
   grep --quiet --line-regexp --fixed-strings "$MUST_NOT_CHANGE"
then
  echo Commit would modify one or more files that must not change.
  exit 1
else
  exit 0
fi

Full Example of the pre-commit Hook

Let’s put all of the above together. Here is the full example of the pre-commit hook that prevents accidental modifications of the Unity project settings file (or any other files you want).

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
29
30
31
32
33
34
35
36
37
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.

# List of files that should not be modified
# Full paths from the repo root separated by newlines
MUST_NOT_CHANGE='
ProjectSettings/ProjectSettings.asset
Assets/Resources/I2Languages.asset
'

# Get the last commit reference
if git rev-parse --verify HEAD >/dev/null 2>&1
then
  against=HEAD
else
  # Initial commit: diff against an empty tree object
  against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi

# Redirect output to stderr
exec 1>&2

# Check if the commit contains changes to the files that must not change.
if git diff --cached --name-only $against |
   grep --quiet --line-regexp --fixed-strings "$MUST_NOT_CHANGE"
then
  # Block the commit
  echo Commit would modify one or more files that must not change.
  exit 1
else
  # Accept the commit
  exit 0
fi

How to Handle Git Hooks in Unity

Now, we have a working pre-commit hook, which does the job and prevents modification for specified files. But it’s not very convenient for each team member to manually copy it to .git/hooks directory. And it’s not very convenient to update it across the team if we need to change something.

As a result, we just replaced one human factor with another. Like replacing a lost left sock with a right one - it’s a change, but we’re still not quite on the right foot! Not exactly what we are trying to achieve, huh? So let’s automate it then!

Git Hooks Registrator

For those purposes, we’ll create a Unity editor script that automatically registers the pre-commit hook.

1
2
3
4
5
6
namespace EditorTools
{
    public class GitHooksRegistrator
    {
    }
}

Let’s add the [InitializeOnLoad] attribute now. It will ensure that the class is initialized when Unity editor is loaded. And add the static constructor. It will be called when the class is initialized, and it’s a perfect place to register the hook. Because it will be triggered each time you open the project.

1
2
3
4
5
6
7
8
[InitializeOnLoad]
public class GitHooksRegistrator
{
    static GitHooksRegistrator()
    {
        RegisterPreCommitHook();
    }
}

Now, let’s add constants for the path to the pre-commit hook and its template. Both should be relative to the repo root.

1
2
private const string PRE_COMMIT_PATH = ".git/hooks/pre-commit";
private const string PRE_COMMIT_TEMPLATE_PATH = "Assets/Scripts/Editor/Hooks/pre-commit";

Then, let’s actually implement the RegisterPreCommitHook method. We’ll use the Application.dataPath variable to get the path to the Assets directory. And the FileUtil.ReplaceFile method to copy the template to the hook path.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static void RegisterPreCommitHook()
{
    try
    {
        if (!File.Exists(PRE_COMMIT_TEMPLATE_PATH))
        {
            Debug.LogError("Pre-commit template not found.");
            return;
        }
        
        FileUtil.ReplaceFile(PRE_COMMIT_TEMPLATE_PATH, PRE_COMMIT_PATH);
        
        Debug.Log($"Successfully registered git pre-commit hook");
    }
    catch (Exception e)
    {
        Debug.Log($"Failed to register git pre-commit hook with error: {e}");
    }
}

Registrator QoL Improvements

Auto-registration should work now. But I would like to add a few “quality of life” improvements. First, let’s add a menu item to register the hook manually. It would be helpful if you need to re-register the hook after you changed something in the template.

1
2
3
4
5
[MenuItem("Tools/Git/Pre-commit/Register hook")]
private static void RegisterPreCommitHook()
{
    ...
}

Also, your team members may have their own private hooks already. I bet they’ll not be happy if we’ll silently overwrite it. So, let’s add a backup for the previous hook.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private const string PRE_COMMIT_BAK_PATH = PRE_COMMIT_PATH + ".bak";

[MenuItem("Tools/Git/Pre-commit/Register hook")]
private static void RegisterPreCommitHook()
{
        ...
        
        if (File.Exists(PRE_COMMIT_PATH))
        {
            FileUtil.ReplaceFile(PRE_COMMIT_PATH, PRE_COMMIT_BAK_PATH);
            Debug.Log("Previous pre-commit hook found. Backup created.");
        }
        
        FileUtil.ReplaceFile(PRE_COMMIT_TEMPLATE_PATH, PRE_COMMIT_PATH);
        
        ...
}

Registrator Versioning

And finally, let’s use a versioning for the hook. With proper versioning, we could avoid re-registering the hook each time we open the project. Also, this will guarantee that the hook will be re-registered for all team members if we change something in the template.

We’ll use EditorPrefs for that. First, let’s add a constant for the version key. It should be unique for each project, so let’s use the Application.dataPath variable as a prefix here. And the Base64Encode method to avoid issues with special characters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static readonly string PRE_COMMIT_PREF_KEY;

static GitHooksRegistrator()
{
    PRE_COMMIT_PREF_KEY = $"{Base64Encode(Application.dataPath)}.git_hook_pre_commit";
    
    ...
}

private static string Base64Encode(string plainText)
{
    var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
    return Convert.ToBase64String(plainTextBytes);
}

Then, let’s add the int constant for the current version. If you need to re-register the hook, just increment it. Now, our registrator will compare the current version with the installed one (saved in EditorPrefs), and re-register the hook if needed.

1
2
3
4
5
6
7
8
9
10
11
12
13
private const int PRE_COMMIT_VERSION = 1;
private static readonly string PRE_COMMIT_PREF_KEY;

static GitHooksRegistrator()
{
    PRE_COMMIT_PREF_KEY = $"{Base64Encode(Application.dataPath)}.git_hook_pre_commit";
    
    var installedVersionKey = EditorPrefs.GetInt(PRE_COMMIT_PREF_KEY, 0);
    if (installedVersionKey < PRE_COMMIT_VERSION)
    {
        RegisterPreCommitHook();
    }
}

Disabling the Hook

Now, we excluded the possibility of accidental modifications of the project settings file. But what if you need to modify it intentionally? Let’s add a menu item to disable the hook temporarily.

For that purpose, we’ll just delete the hook file. And set the version to 0. It will force the registrator to re-register the hook next time you open the project. More than enough time to make your changes!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[MenuItem("Tools/Git/Pre-commit/Disable till next run")]
private static void DisablePreCommitHook()
{
    try
    {
        FileUtil.DeleteFileOrDirectory(PRE_COMMIT_PATH);
        EditorPrefs.SetInt(PRE_COMMIT_PREF_KEY, 0);
        
        Debug.Log($"Disabled git pre-commit hook until next run");
    }
    catch (Exception e)
    {
        Debug.Log($"Fail to disable git pre-commit hook with error: {e}");
    }
}

Final Version of the Registrator

Now, let’s put all of the above together. Here is the final version of the registrator.

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

namespace EditorTools
{
    [InitializeOnLoad]
    public class GitHooksRegistrator
    {
        private const int PRE_COMMIT_VERSION = 1;

        private const string PRE_COMMIT_PATH = ".git/hooks/pre-commit";
        private const string PRE_COMMIT_BAK_PATH = PRE_COMMIT_PATH + ".bak";
        private const string PRE_COMMIT_TEMPLATE_PATH = "Assets/Scripts/Editor/Hooks/pre-commit";

        private static readonly string PRE_COMMIT_PREF_KEY;

        //---------------------------------------------------------------------
        // Ctors
        //---------------------------------------------------------------------

        static GitHooksRegistrator()
        {
            PRE_COMMIT_PREF_KEY = $"{Base64Encode(Application.dataPath)}.git_hook_pre_commit";
            
            var installedVersionKey = EditorPrefs.GetInt(PRE_COMMIT_PREF_KEY, 0);
            if (installedVersionKey < PRE_COMMIT_VERSION)
            {
                RegisterPreCommitHook();
            }
        }

        //---------------------------------------------------------------------
        // Menu
        //---------------------------------------------------------------------

        [MenuItem("Tools/Git/Pre-commit/Register hook")]
        private static void RegisterPreCommitHook()
        {
            try
            {
                if (!File.Exists(PRE_COMMIT_TEMPLATE_PATH))
                {
                    Debug.LogError("Pre-commit template not found.");
                    return;
                }
                
                if (File.Exists(PRE_COMMIT_PATH))
                {
                    FileUtil.ReplaceFile(PRE_COMMIT_PATH, PRE_COMMIT_BAK_PATH);
                    Debug.Log("Previous pre-commit hook found. Backup created.");
                }
                
                FileUtil.ReplaceFile(PRE_COMMIT_TEMPLATE_PATH, PRE_COMMIT_PATH);
                EditorPrefs.SetInt(PRE_COMMIT_PREF_KEY, PRE_COMMIT_VERSION);
                
                Debug.Log($"Successfully registered git pre-commit hook");
            }
            catch (Exception e)
            {
                Debug.Log($"Fail to register git pre-commit hook with error: {e}");
            }
        }
        
        [MenuItem("Tools/Git/Pre-commit/Disable till next run")]
        private static void DisablePreCommitHook()
        {
            try
            {
                FileUtil.DeleteFileOrDirectory(PRE_COMMIT_PATH);
                EditorPrefs.SetInt(PRE_COMMIT_PREF_KEY, 0);
                
                Debug.Log($"Disabled git pre-commit hook until next run");
            }
            catch (Exception e)
            {
                Debug.Log($"Fail to disable git pre-commit hook with error: {e}");
            }
        }
        
        //---------------------------------------------------------------------
        // Helpers
        //---------------------------------------------------------------------

        private static string Base64Encode(string plainText)
        {
            var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
            return Convert.ToBase64String(plainTextBytes);
        }
    }
}

Conclusion

We’re only human. We make typos, forget to turn off the stove, and sometimes accidentally mess up our project settings. That’s why adding a bit of magic to our workflow is awesome. The magic of Git hooks, those tiny, vigilant elves living in your .git folder, and always ready to shout “Stop!” to prevent bad commits! And for an extra sprinkle of awesomeness, we’ve got our Unity editor script automating the process for the whole team.

There are many more use cases for Git hooks and how to make useful Unity integrations. But I hope this article will help you to get started. And if you have any questions or suggestions, feel free to leave a comment below (when I’ll finally make them work…).

This post is licensed under CC BY 4.0 by the author.