Git Hooks 🤝 Swift
Here in SwiftToolkit.dev we’ve already repeated this motto: better than having great tools, is making them run automatically, in the right time. The right moment will depend on the workflow: sometimes, it’s based on a schedule; in other scenarios, maybe a webhook triggers an action. As a developer, chances are high that you write code and commit to a Git repository.
The moments when you perform a commit, push, or merge them, might be the perfect time to run a script or a custom executable. Fortunately, Git provides built-in checkpoints at these moments, and it’s a good practice to use them to enforce coding standards, catch errors, and perform validations, among other actions.
Usually, Git hooks are written in bash, but anything that you can execute as a program can serve as a hook. In this post, you’ll learn how to set up Git hooks in a project, starting with a simple bash script, and then moving on to Swift - both as a script and as a compiled executable.
Available Git Hooks
The scripts or compiled executables are located in the .git/hooks
directory, and are named after the hook type. For example, the pre-commit
hook has the path .git/hooks/pre-commit
.
There are two types of hooks: client-side and server-side. This article focuses on the client-side hooks, which are executed on the machine where the developer is working, and not the server that hosts the repository.
The most common hooks are:
pre-commit
: runs before a commit is made. You can use it to perform linting, formatting, or other checks. If the hook returns a non-zero exit code, which indicates an error, the commit is aborted.commit-msg
: runs after the commit message is edited. You can use it, for example, to validate the commit message. Similar to the previous hook, returning a non-zero exit code will abort the commit.post-commit
: runs after a commit is made.pre-push
: runs before a push is made. You can choose to perform linting or formatting checks at this stage, and either abort the push or amend to the latest commit.
You can read more about Git hooks in the official documentation.
Setting up a Simple Git Hook
A simple bash script for the pre-commit
hook can be a good starting point. We’ll create a hook that runs SwiftLint to check for linting errors, but first, checking if SwiftLint is installed.
To create a file named pre-commit
in the .git/hooks
directory, you can run:
touch .git/hooks/pre-commit
Open it in your preferred text editor, adding the following content:
#!/bin/bash
if command -v swiftlint &> /dev/null; then
swiftlint . --strict
else
echo "SwiftLint is not installed, run 'brew install swiftlint' to install it"
exit 1
fi
If SwiftLint is installed, this script will perform linting in the strict
mode, and abort the commit if there are any errors.
Every hook requires executable permissions, so add them to the file first:
chmod +x .git/hooks/pre-commit
In a machine without SwiftLint, when trying to commit a file, Git will not allow you to commit, because of the exit 1
line, and the message in the line above will be displayed:

After installing SwiftLint, it will run and check for linting errors. For a file that contains a violation of the opening_brace
rule, trying to commit will result in the following output:
Done linting! Found 2 violations, 2 serious in 1 file.
After fixing the linting errors, the commit can be performed without any issues.
Swift as a Git Hook
If you’re not an expert in bash, doing things such as string manipulations can be challenging and hard to maintain. Imagine you want to create a hook that checks if a commit message is valid, and contains a prefix such as [feature]
, [bugfix]
, or [infra]
.
Create this time a file named commit-msg
in the .git/hooks
directory, and add the following content:
#!/usr/bin/env swift
import Foundation
// 1
func exitWithErrorMessage(_ message: String) -> Never {
print(message)
exit(1)
}
// 2
guard CommandLine.arguments.count > 1 else {
exitWithErrorMessage("Error: Commit message file path not provided")
}
let commitMessagePath = CommandLine.arguments[1]
// 3 Read the commit message from the file
guard let commitMessage = try? String(contentsOfFile: commitMessagePath, encoding: .utf8) else {
exitWithErrorMessage("Error: Could not read commit message file")
}
This is the first half of the script. Notice the first line, containing the shebang, which tells the system how to run the script, by using the Swift interpreter. After that:
- Create a function to exit the script with an error message, returning
Never
, which allows the function to be used in theelse
branch of theguard
statements. - The
commit-msg
hook passes the path of a temporary file, containing the commit message, as an argument. Make sure it exists before trying to read it. - Read the commit message from the file.
Next, you need to perform the validation of the commit message itself. Below the lines above, add the following content:
// 1
let validPrefixes = ["[feature]", "[bugfix]", "[infra]"]
// 2
var hasValidPrefix = false
for prefix in validPrefixes {
if commitMessage.hasPrefix(prefix) {
hasValidPrefix = true
break
}
}
// 3
if !hasValidPrefix {
let message = "Commit message must start with one of these prefixes: (validPrefixes.joined(separator: ", "))"
exitWithErrorMessage(message)
}
And this is all you need:
- Define the valid prefixes you want to enforce.
- Iterate over the prefixes, checking if the commit message starts with any of them.
- If the commit message doesn’t start with any of the prefixes, abort the commit with a message containing the valid prefixes.
Now, trying to perform a commit with a message without any of the prefixes will result in the following output:

You can notice that the linting was performed succefully this time (from the pre-commit
hook), but now the commit was aborted because of the invalid commit message. If you use a GUI, you’ll also get the message as an error alert:


Compiled Swift as a Git Hook
You might have noticed, that the commit-msg
hook is a single file. If you have more complex requirements, or want to perform more types of validations, it can be easily become hard to maintain.
Additionally, you might want to inspect the contents of the files that are being commited. To do this in Swift, you need to execute the git diff
command, and load the contents of each file. Running system commands in Swift is possible, and if you decide to use a package dependency to make it easier (such as Command)
When using any package, it’s preferrable to use the Swift Package Manager to compile your executable - as running swift run ...
every time you need to commit will take longer, and a terrible developer experience.
A Practical Example: Avoiding Prints and TODOs
The following example will use the Command package to execute git diff
, load the contents of each file, and reject the commit if any of them contains a print or a TODO.
import Command
import Foundation
@main struct NoPrints {
static func main() async throws {
// 1
let changedFilesCommand = try await Command.run(arguments: [
"git",
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR"
]).concatenatedString()
// 2
guard !changedFilesCommand.isEmpty else { return }
let changedFiles = changedFilesCommand.split(separator: "\n")
let currentDirectory = FileManager.default.currentDirectoryPath
for file in changedFiles {
// 3
let fileURL = URL(fileURLWithPath: currentDirectory)
.appendingPathComponent(String(file))
let contents = try String(contentsOfFile: fileURL.path())
// 4
if contents.contains("print(") || contents.contains("// TODO") {
print("The file (file) contains print statements or TODO comments.")
exit(1)
}
}
}
}
This single main
function will do:
- Run the git diff command to get the list of changed files: added, copied, modified or renamed
- If there is a non-empty output, separate the files by new lines, and iterate over them
- Compose the full path to the file, taking the current directory into account, and load its contents
- Check if the contents contain a print or a TODO, and exit with a non-zero code to abort the commit
Finally, compile the executable with swift build -c release
, and copy it to the .git/hooks
directory, with the pre-commit
name.
Explore Further
Having Git Hooks in place can be a good practice! By having them in your project setup, it can be useful for you and your team to detect issues early, perform general validations, and enforce coding standards, and automate other tasks.
Git Hooks are not commited to the repository, so installing them in the correct directory is another task you need to perform - such as having a build script that copies them to the .git/hooks
directory.
I hope you enjoyed this tutorial! Feel free to ask any questions, send suggestions or feedback via Mastodon or X.
See you at the next post. Have a good one!