DEV Community

𒎏Wii 🏳️‍⚧️
𒎏Wii 🏳️‍⚧️

Posted on

Sh*t, 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
Enter fullscreen mode Exit fullscreen mode

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"/
Enter fullscreen mode Exit fullscreen mode

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-
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
auroratide profile image
Timothy Foster

Ooh, nice trick! We currently have a git hook that basically looks at json files for "password" and rejects the commit if that line has a value. It use to look at every json file, but that took so long we had to in-list the specific json files in question. Somehow none of us thought to use git status --porcelain to check only the files that changed!