Shit, I didn't mean to commit that!
We've all been there ๐
When working on a project, specially in a team, and doubly so if there's other things that occasionally distract us from the task at hand, it is easy to commit changes that were meant to stay local.
One may add a line that wipes a database at the start of an import for a tighter testing cycle, add debug outputs, etc.
As life gets in the way, we take breaks, coworkers want other things from us, or we simply have our head filled with other aspects of the task, one can easily forget to remove these things before committing and pushing.
But it doesn't have to be this way ๐ฎ
Git hooks can help us with this. We just have to think up a way for us to mark changes that should never make it into a commit and write a hook to detect them.
Here, I will use the string "nocommit": if this appears anywhere in the changes I'm going to commit, I want git to reject my commit and tell me where it was found.
Let's get to work ๐
Writing git hooks is surprisingly easy: They're really just shell scripts that run in the project directory. Some get arguments, but we don't need them here.
We care about the pre-commit
hook. To enable it, just write a file to .git/hooks/pre-commit
in your git repository and set its executable permission. To make it reject all commits, put this in the file:
#/bin/sh
exit 1
To accept all commits, just have it return 0 instead.
Now, to get to the interesting part:
Get our files ๐
For starters, we'll need access to the files as they will get committed. We don't want our entire commit to fail if a there's a file with a nocommit
mark that isn't even going to get committed.
Luckily, this is relatively easy to achieve. We can check out the index of the repository into a temporary directory like this:
tmp_dir=$(mktemp -p /dev/shm -d -t "git-index.XXXX")
git checkout-index -a --prefix="$tmp_dir"/
The first command just gives us a temporary directory in /dev/shm
and makes sure it is unique.
The second command checks out the entire index into this temporary directory.
But there isn't really much of a point in checking every file in the repository; we can just limit our search go the files that have been modified.ย To list these, we can use this command:
git status --porcelain | cut --bytes 4-
Get our marks ๐
Now comes the juicy part. Having our files, we want to scan them for a keyword. For better readability, we can put this part in a function:
get_marks() {
git status --porcelain | cut --bytes 4- | while read file
do
if [ -e "$tmp_dir/$file" ]
then
grep -n -i nocommit "$tmp_dir/$file" | sed "s/:/ /" | while read line
do
echo "$file:$line"
done
fi
done
}
We start with listing the files as described above, then pipe that into a sh
while loop that reads each individual line.
We then use the grep
command to search for "nocommit" in our target $file
in the $tmp_dir
directory, ignoring case (-i
) and prepending the line number (-n
). The result then gets piped though sed
to turn the single colon :
separating the line number from the line into a normal space. This last part is just a matter of taste, honestly.
The results are then again piped into another loop, which simply prints the file name and the line. The resulting lines would look something like this:
path/to/file.js:14 console.log("I hate my job") // nocommit
Evaluate the results ๐
Now that we can extract the lines we care about, it's time to decide if the commit is good or not.
We can do this with a simple if
:
marks=$(get_marks)
if [ -n "$marks" ]
then
/bin/echo -e "\x1b[31m'no$THEWORD' mark(s) found:\x1b[0m"
echo $marks
status=1
else
status=0
fi
First we call our function to get the marks and save its output into a marks
variable. If it's empty, we set a status variable to 0. We can't exit yet because we still need to clean up after ourselves. If marks
isn't empty, we print a warning, then the marks, and set our status to 1.
Cleanup ๐งน
After this we're basically done with the important part; but the temporary directory is still around and we should delete it now that we're done with it. After that, we can just exit with the status we decided on earlier.
rm -r $tmp_dir
exit $status
And there you have it ๐
Here's the finished script. I made one more modification to make sure the script itself can actually be committed without problem ๐
#!/bin/sh
export THEWORD=commit
tmp_dir=$(mktemp -p /dev/shm -d -t "git-index.XXXX")
git checkout-index -a --prefix="$tmp_dir"/
get_marks() {
git status --porcelain | cut --bytes 4- | while read file
do
grep -n -i no$THEWORD "$tmp_dir/$file" | sed "s/:/ /" | while read line
do
echo "$file:$line"
done
done
}
marks=$(get_marks)
if [ -n "$marks" ]
then
/bin/echo -e "\x1b[31m'no$THEWORD' mark(s) found:\x1b[0m"
echo $marks
status=1
else
status=0
fi
rm -r $tmp_dir
exit $status