Talia's Blog

Shit, I didn't mean to commit that!

We've all been there. We changed something that wasn't meant to get pushed, forgot about it, and accidentally committed & pushed it. Here's a neat trick to help avoid this.
gitshellbeginner

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