How to commit executable shell scripts with Git on Windows

Whenever you develop UNIX shell scripts on Microsoft Windows and commit them with Git, they lack the UNIX execute permission bit. When you check out the file with Git on a UNIX-based system, these scripts cannot be started, and the software breaks. This article explains how to set the execute permission bit on Windows, and how to automate the process.

Introduction

On UNIX-based systems, shell scripts are files with a “.sh” extension that can be executed like binary programs, as long as the execute permission bit is set. This is typically achieved by a command like chmod +x script.sh.

Git is able to transfer UNIX permissions as part of committing and checking out files. This works right out of the box if you develop on UNIX-based machines, like Linux distribution, or macOS. A script.sh file that was made executable prior to committing it with Git will also have the execute permission bit set, when checking out the respective branch on a different UNIX machine.

However, if you develop software involving shell scripts on Windows, which should also run on UNIX, you have a problem. Windows filesystems like NTFS do not support UNIX permission bits. Whenever you create new shell scripts on Windows, or rename existing ones (which may have been executable at the time of check-out), these won’t be executable. When you push the code, these scripts won’t run a UNIX-based machine. [Side note: if you merely edit an existing shell script on Windows (already under version control) and commit the change, the execute bit in Git won’t be affected and will stay in place!]

Git offers commands to discover the UNIX permission bits of your files, or to set (virtual) permission bits in your staging area (also called Git index) on Windows. Let’s see how to use these commands manually, followed by an automated approach that fixes permissions on each commit automatically.

Fixing permissions the manual way

To discover which shell scripts are lacking the execute bit, we can run the Git ls-files command. An example is shown below, assuming your working directory in the command line window is the root of your Git repository:


git ls-files -s – *.sh Output: 100644 b9945... 0 sources/some-script.sh 100644 f29de... 0 sources/some-other-script.sh 100755 5d107... 0 sources/correct-permissions.sh ...

The -s flag considers the current state of the staging area, and forces the output to have above form, including the permission bits. The -- syntax limits the listed files to those with the .sh extension. Let’s look at the output. The first column indicates the permission bits. Ignoring the first three digits, a value of 100644 indicates that it is a regular file without the execute bit. The third file in the above output has permission bits 100755, thus it has the execute bit set. I’m assuming that you understand UNIX permission bits and know what 644 or 755 means. The second and third column are the file hash and the stage number, see here for more information.

To make a shell script executable, use the following command. Don’t forget to commit and push your changes after running it!

git update-index – chmod=+x script.sh

An automated approach using Git commit hooks

To avoid mistakes we can automate the process, using Git hooks. They are scripts that Git executes automatically on specific events, such as before or after committing or pushing. We will use the pre-commit hook to make all those shell scripts executable in the Git index which aren’t executable yet, prior to committing. I’ve written a small Python 3 script that does the job for you, so you’ll need Python 3 for this to work. Just follow below instructions.

Create a new file <repository root>/.git/hooks/pre-commit (without any file extension) with the following content:

#!/usr/bin/env python import subprocess if __name__ == '__main__': output = subprocess.check_output(["git", "ls-files", "-s", "--", "*.sh"], shell=True).decode("utf-8") # type: str files_to_fix = [] for line in output.splitlines(): # Example for "line": '100644 82f6a7d558e1b38c8b47ec5084fe20f970f09981 0 test-update.sh' entry = line.replace('\t', ' ').split(" ", maxsplit=3) mode = entry[0][3:] # strips the first 3 chars ("100") which we don't care about filename = entry[3] if mode == "644": files_to_fix.append(filename) for file_path in files_to_fix: # git update-index – chmod=+x script.sh subprocess.check_call(["git", "update-index", "--chmod=+x", file_path], shell=True)

You may have to adapt the first line in case python.exe is not on PATH. If you’re unsure, just open the Git bash program and type which python to determine whether this is the case, and what its actual location is. If you want to provide an absolute path to your Python interpreter instead, make sure to use the UNIX-style notation. For instance, put #!/c/Python/python in the first line of the file, to use C:\Python\python.exe.

Note: as documented here, older versions of Git on Windows had problems with the shebang operator, so I recommend that you update Git to a later version, which is good idea anyway.

Automatic pre-commit hook setup

To avoid that you have to repeat this for every Git repository, you can use Git’s template directory mechanism as follows:

1) Create an empty directory somewhere, which will contain all your hooks, and put the pre-commit file inside

2) Run git config – global init.templatedir <path to dir of step 1>
From now on, all new Git repositories you create will have the pre-commit hook installed automatically.

3) To install the hook into an already existing Git repository, run git init in that repository’s root. This command is non-destructive. To quote the docs: “It will not overwrite things that are already there. The primary reason for rerunning git init is to pick up newly added templates”.

Conclusion

Handling the UNIX permission bit is a pain if you develop on Windows, because you will forget to set it, prior to committing. Using the provided script, triggered by the pre-commit hook, will solve this problem once and for all.

Leave a Comment