Sh*t, I didn't mean to commit that!
Tags: git, shell, beginners, tutorial
Published: 2024-04-05 10:40:15

---
title: Sh*t, I didn't mean to commit that!
published: true
description: 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.
tags: git, shell, beginner, #tutorial
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2024-03-10 15:24 +0000
---

## 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:

```sh
#/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:

```sh
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:

```sh
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:

```sh
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`:

```sh

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.

```sh
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 😅

```sh
#!/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
```