Write a Pre-Commit Hook for Managing Comments

Taylor Galloway
Red Squirrel
Published in
6 min readJul 14, 2023
Credit Grace To from Unsplash

The codebase I’ve been working in has slowly accumulated comments over time. Some comments explain nearby code, some suggest future improvements, and some point out current or potential issues. Some comments are useful and remain relevant, but unsurprisingly, most remain unaddressed and forgotten. Comments like these are written for future developers, but comments can also be used by the current developer in the middle of their workflow. They can help a developer pseudocode, keep a piece of to-be-deleted code for temporary reference, or remember things to come back to. However comments are used, it’s easy for them to quickly become irrelevant, confusing to other developers, and make it into commits unintentionally.

To help address these issues with managing comments in addition to helping myself learn more about zsh scripting, I wrote a pre-commit hook which checks for labels at the beginning of comments, outputs search results, and prevents a commit if there are any unintentionally leftover comments.

These steps are for building this hook in a JavaScript project, but can be easily translated to other languages.

Step 1: Add Labels

Prepend the content of your comments with a consistent label in a distinguishing format like an all caps “FIXME” or “TODO” or “NOTE”

Step 2: Categorize Labels

Categorize these labels by whether they should allow a commit to be accepted or not.

comment_labels=("FIXME" "TODO" "DELETEME" "NOTE")
comment_labels_no_commit=("NOCOMMIT")

Step 3: Set up Git Hook Library

Add a git hook library like Husky for JS projects and follow its set-up instructions.

yarn add husky

Create a file for the hook ex: check-comments.sh

#!/bin/sh
comment_labels=("FIXME" "TODO" "DELETEME" "NOTE")
comment_labels_no_commit=("NOCOMMIT")

Add command to package.json

{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"check-comments": "./check-comments.sh"
}
}

Call command from husky script

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn check-comments

Step 4: Customize Grep

At the heart of this script is grep

Define your grep flags and arguments and wrap it in a function so it can be re-used in the script.

grep_wrapper() {
grep "$@" -r -F -n --include=*.{jsx,js} --exclude-dir=node_modules --color
}

"$@” is a parameter which expands all arguments passed to the grep_wrapper function call into a string literal.

-r or — recursive means the search will recursively search through all directories and sub-directories where grep is called (we want it to search through an entire project)

-F or — Fixed-Strings means we will be able to search for the labels themselves

-n or --line-number will output the line number and file path of where the matching label was found

— include=*.{jsx,js} and — exclude-dir=node_modules specify file types to include and directories to exclude

--color will highlight the matching labels in the output

You can test the output of this function by calling it in your script with a sample argument like: grep_wrapper "FIXME"

Step 5: Create a function which changes behavior depending on the label type

Create a function to loop through the labels passed in, call grep on each label and change behavior depending on the label type (this will take a few steps to build).

check_comments() {
label_type="$1"
labels=("${@:2}")
}
check_comments "COMMIT" "${comment_labels[@]}"
check_comments "NOCOMMIT" "${comment_labels_no_commit[@]}"

The first argument ex. "COMMIT”is the label type and the second argument passes in the array as a single string by expanding the array with spaces as separators between elements. Then inside the function the arguments are captured as string literals assigned to variables with clear names.

The second argument "${comment_labels[@]}" expands the comment_labels variable into a series of separate string elements

label_type="$1" captures the first argument passed to check_comments

labels=("${@:2}") creates an array of strings starting with the 2nd argument until the last argument

Step 6: Call grep on each label

Loop through the labels and call the grep_wrapper function on each label.

for label in "${labels[@]}"; do
results=$(grep_wrapper "$label")
done

This is a for in loop which calls grep_wrapper on each iteration and stores the output in an array.

Step 7: Connect the script output to git hook library

Prevent a commit from going through by checking the label type and checking if any matches were found after calling grep.

should_not_commit=false
for label in "${labels[@]}"; do
results=$(grep_wrapper "$label")
grep_status="$?"
if [[ "$grep_status" = 0 && "$label_type" = "NOCOMMIT" ]]; then
should_not_commit=true
fi

if [[ "$grep_status" = 0 ]]; then
echo "$results"
fi
done

if [[ "$should_not_commit" = true ]]; then
echo "There were NOCOMMIT comment labels matched\n\n"
exit 1
fi

grep_status="$?" captures the exit code of the most recent operation. If matches were found the output will be 0 otherwise it will be 1.

So the subsequent conditional checks if a match was found for a NOCOMMIT label and will change should_not_commit to true. This is checked further down outside of the loop and will cause the script to exit with code 1 so that Husky knows to prevent the commit.

If matches are found then the next conditional will output the grep results.

Step 8: Add descriptive messages

Improve output readability by using echo to print descriptive messages around the grep output.

Also, provide info about the number of matches found per label by passing the grep results into wc -l using the here string operator <<<. wc -l calculates the number of separate lines in its input.

check_comments() {
label_type="$1"
labels=("${@:2}")
echo "Comment label type: ${label_type}"
echo "Comment label(s): ${labels[*]}\n\n"
should_not_commit=false

for label in "${labels[@]}"; do
results=$(grep_wrapper "$label")
grep_status="$?"
if [[ "$grep_status" = 0 && "$label_type" = "NOCOMMIT" ]]; then
should_not_commit=true
fi

if [[ "$grep_status" = 0 ]]; then
count=$(wc -l <<< "$results")
echo "\nFound ${label} comment label ${count} time(s)"
echo "$results"
else
echo "\nNo matches found for: ${label}\n"
fi
done

if [[ "$should_not_commit" = true ]]; then
echo "There were NOCOMMIT comment labels matched\n\n"
exit 1
fi
}

Step 9: More output readability improvement

Add a line of characters to distinguish between each time check_comments is called.

columns=$(tput cols)

check_comments() {
label_type="$1"
labels=("${@:2}")
echo "\n"
printf "%0${columns}d\n" 0 | tr 0 -

"%0${columns}d\n" is the format string argument passed to printf. It tells the utility to pad its input with a number of 0 characters equal to the width(number of columns) in the user’s terminal. tput cols is used to access this number.

The results of printf are piped to tr which replaces all of the 0 characters with - characters.

Step 10: Add color

Add more color to the output to highlight important information.

CYAN=$'\e[36m'
NC=$'\e[m'

check_comments() {
label_type="$1"
labels=("${@:2}")
echo "\n"
printf "%0${columns}d\n" 0 | tr 0 -
echo "Comment label type: ${CYAN}${label_type}${NC}"
echo "Comment label(s): ${CYAN}${labels[*]}${NC}\n\n"
should_not_commit=false

for label in "${labels[@]}"; do
results=$(grep_wrapper "$label")
grep_status="$?"
if [[ "$grep_status" = 0 && "$label_type" = "NOCOMMIT" ]]; then
should_not_commit=true
fi

if [[ "$grep_status" = 0 ]]; then
count=$(wc -l <<< "$results")
echo "\nFound ${CYAN}${label}${NC} comment label ${CYAN}${count}${NC} time(s)"
echo "$results"
else
echo "\nNo matches found for: ${CYAN}${label}${NC}\n"
fi
done

if [[ "$should_not_commit" = true ]]; then
echo "There were NOCOMMIT comment labels matched\n\n"
exit 1
fi
}

The variables CYAN and NC contain strings with escape sequences which tells the terminal to color the text and to stop coloring the text. NC stands for “no color”.

Step 11: Test the script

Add some sample comments in your code, test the script’s output by running yarn check-comments, and confirm that commits do not go through when NOCOMMIT labels are found.

Conclusion

Here’s the full script:

#!/bin/sh

comment_labels=("FIXME" "TODO" "DELETEME" "NOTE")
comment_labels_no_commit=("NOCOMMIT")
columns=$(tput cols)
CYAN=$'\e[36m'
NC=$'\e[m'

grep_wrapper() {
grep "$@" -r -F -n --include=*.{jsx,js} --exclude-dir=node_modules --color=always
}

check_comments() {
label_type="$1"
labels=("${@:2}")
echo "\n"
printf "%0${columns}d\n" 0 | tr 0 -
echo "Comment label type: ${CYAN}${label_type}${NC}"
echo "Comment label(s): ${CYAN}${labels[*]}${NC}\n\n"
should_not_commit=false

for label in "${labels[@]}"; do
results=$(grep_wrapper "$label")
grep_status="$?"
if [[ "$grep_status" = 0 && "$label_type" = "NOCOMMIT" ]]; then
should_not_commit=true
fi

if [[ "$grep_status" = 0 ]]; then
count=$(wc -l <<< "$results")
echo "\nFound ${CYAN}${label}${NC} comment label ${CYAN}${count}${NC} time(s)"
echo "$results"
else
echo "\nNo matches found for: ${CYAN}${label}${NC}\n"
fi
done

if [[ "$should_not_commit" = true ]]; then
echo "There were NOCOMMIT comment labels matched\n\n"
exit 1
fi
}

check_comments "COMMIT" "${comment_labels[@]}"
check_comments "NOCOMMIT" "${comment_labels_no_commit[@]}"

I hope that by following this tutorial you have learned a little more about zsh and scripting as well as have a new tool to better manage comments in your code.

--

--