Introduction
The use of Semantic Versioning is very important to keep your users informed about changes that may impact how they interact with the software, maintain compatibility between libraries, facilitate collaboration among teams by reducing conflicts and communication failures regarding the current state of the software, among many other things.
That said, keeping semantic versioning flawless can be quite a challenge, either because not everyone who contributes to the code practices it correctly — such as incorrectly changing the MAJOR, MINOR, and PATCH components — or because we may simply forget to update the version before committing or pushing the code to the repository. In any case, manual processes are always prone to human error, so it would be interesting if we could create an automated way to validate whether a version bump is necessary to prevent potential issues.
Fortunately, Git has hooks, which are scripts that Git automatically runs before or after certain events like commit or push. We can leverage them to alert us to possible changes that break code compatibility, keeping semantic versioning neat and tidy 😊.
What is Husky?
Husky is an NPM package that makes it easy to integrate Git Hooks into your project. It can be used to automate tasks such as running tests, linters, etc. It is extremely fast and weighs only 2kb. Additionally, with Husky, we can create hooks using POSIX shell scripts.
How to install Husky
Use your favorite package manager to install the dependency:
yarn add --dev husky
Use NPX to automatically setup husky for you:
npx husky init
This will create a pre-commit
script in .husky/
folder and updates prepare
script in your package.json
file. Boom! its all good to go.
Creating a pre-commit hook to validate possible breaking changes in the code and prevent us from pushing them without incrementing MAJOR version.
On .husky/
folder, open the pre-commit
file that Husky has already created for you and paste the following code. Don’t worry, we will talk about what it’s doing in a second.
yellow='\\033[0;33m'
green='\\033[0;32m'
blue='\\033[0;34m'
red='\\033[0;31m'
no_color='\\033[0m'
ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED=0
ABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED=1
POTENTIALLY_BREAKABLE_CHANGES=0
set -o nounset
compare_strings() {
old_string="$1"
new_string="$2"
# Initialize the resulting string
result=""
# Initialize the indexes
i=0
j=0
# Traverse the new string and compare it with the old string
while [ $i -lt ${#new_string} ]; do
new_char="${new_string:i:1}"
old_char="${old_string:j:1}"
# If the character from the new string is equal to the old one, add it without highlight
if [ "$new_char" = "$old_char" ]; then
result="$result$new_char"
i=$((i+1))
j=$((j+1))
else
# If the character from the new string is not in the old string, highlight it in green
if [ "$new_char" != "$old_char" ] && [ ! "$new_char" = "$old_char" ]; then
result="$result${green}${new_char}${no_color}"
i=$((i+1))
else
# Add characters from the old string until finding the matching character
while [ "$new_char" != "$old_char" ] && [ $j -lt ${#old_string} ]; do
result="$result${red}${old_char}${no_color}"
j=$((j+1))
old_char="${old_string:j:1}"
done
fi
fi
done
# Add the remaining characters from the old string, if any
while [ $j -lt ${#old_string} ]; do
result="$result${red}${old_string:j:1}${no_color}"
j=$((j+1))
done
# Return the result
echo -e "${result}"
}
printf "${blue}Initializing Husky${no_color}\\n"
REPO_ROOT=$(git rev-parse --show-toplevel)
SITE_CHANGES=$(git status -s "$REPO_ROOT" | wc -l)
printf "Detected ${yellow}$SITE_CHANGES${no_color} changes\\n"
if [ "$SITE_CHANGES" -gt 0 ]; then
# Check files for function signature changes
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(ts|tsx|js|jsx)$')
if [ -n "$CHANGED_FILES" ]; then
printf "${blue}Checking TypeScript files for potentially breakable changes...${no_color}\\n"
# Define the regex pattern and dont ask me how I get this right (hint: ends with "gpt")
REGEX="function\\s+[a-zA-Z_$][0-9a-zA-Z_$]*\\s*\\([^)]*\\)(?:\\s*:\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\s*(?:;|\\{)|[a-zA-Z_$][0-9a-zA-Z_$]*\\s*=\\s*\\([^)]*\\)(?:\\s*:\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\s*=>\\s*[^\\{]*?(?:;|\\s*)|(?:public|private|protected)?\\s*[a-zA-Z_$][0-9a-zA-Z_$]*\\s*\\([^)]*\\)(?:\\s*:\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\s*(?:;|\\{)"
# Iterate over staged TypeScript files
for FILE in $CHANGED_FILES; do
if [[ -f "$REPO_ROOT/$FILE" ]]; then
printf "Checking file ${yellow}$FILE${no_color}...\\n"
# Get added and removed changes
STAGED_ADDITIONS=$(git diff --cached "$REPO_ROOT/$FILE" | grep -E "^\\+[^+]" | sed 's/^\\+//')
STAGED_REMOVALS=$(git diff --cached "$REPO_ROOT/$FILE" | grep -E "^\\-[^-]" | sed 's/^\\-//')
ADDITIONS_MATCHED=""
REMOVALS_MATCHED=""
# Check if STAGED_ADDITIONS is not empty and execute grep if not
if [ -n "$STAGED_ADDITIONS" ]; then
# Capture the part that matches the regex
ADDITIONS_MATCHED=$(printf "%s" "$STAGED_ADDITIONS" | grep -P -o "$REGEX" || true)
fi
# Check if STAGED_REMOVALS is not empty and execute grep if not
if [ -n "$STAGED_REMOVALS" ]; then
# Capture the part that matches the regex
REMOVALS_MATCHED=$(printf "%s" "$STAGED_REMOVALS" | grep -P -o "$REGEX" || true)
fi
if [ -n "$ADDITIONS_MATCHED" ]; then
printf "${red}Signature changes detected in ${yellow}$FILE${no_color}. Showing changes:\\n"
# Call the function and display the result
compare_strings "$REMOVALS_MATCHED" "$ADDITIONS_MATCHED"
POTENTIALLY_BREAKABLE_CHANGES=1
fi
fi
done
if [ "$POTENTIALLY_BREAKABLE_CHANGES" -eq 1 ]; then
printf "Checking to make sure package version was updated...\\n"
if [ "$ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED" -eq 1 ]; then
VERSION_CHANGED=$(git diff -G '"version":' --cached package.json | wc -l)
if [ "$VERSION_CHANGED" -gt "0" ]; then
printf "${green}Version was updated! Continuing...${no_color}\\n"
else
printf "${red}Version was not updated :( Aborting commit.${no_color}\\n"
exit 1
fi
elif [ "$ABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED" -eq 1 ]; then
CURRENT_VERSION=$(grep -oP '"version":\\s*"\\K[0-9]+\\.[0-9]+\\.[0-9]+"' package.json | tr -d '"')
CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | cut -d'.' -f1)
STAGED_VERSION=$(git diff --cached package.json | grep -oP '"version":\\s*"\\K[0-9]+\\.[0-9]+\\.[0-9]+"' | tr -d '"')
STAGED_MAJOR=$(echo "$STAGED_VERSION" | cut -d'.' -f1)
if [ -n "$STAGED_MAJOR" ]; then
# Check if the MAJOR version has changed
if [ "$CURRENT_MAJOR" != "$STAGED_MAJOR" ]; then
printf "${green}MAJOR version was updated! Continuing...${no_color}\\n"
else
printf "${red}MAJOR version was not updated :( Aborting commit.${no_color}\\n"
fi
else
printf "${red}MAJOR version was not updated :( Aborting commit.${no_color}\\n"
exit 1
fi
fi
fi
fi
fi
Now let’s dive into this code for a minute and see what it is doing.
1. Script Initialization
yellow='\\033[0;33m'
green='\\033[0;32m'
blue='\\033[0;34m'
red='\\033[0;31m'
no_color='\\033[0m'
ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED=0
ABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED=1
POTENTIALLY_BREAKABLE_CHANGES=0
set -o nounset
- First, we create some variables for changing the color of the output.
- The two flags
ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED
andABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED
let’s you have some control of when to abort the push process. -
POTENTIALLY_BREAKABLE_CHANGES
is a flag to identify if the script has found a breakable change in potential. -
set -o nounset
: This enables a mode where using undeclared variables will trigger an error. It helps avoid accidental errors by ensuring that all variables are defined before being used.
2. compare_strings Function
compare_strings() {
old_string="$1"
new_string="$2"
# Initialize the resulting string
result=""
# Initialize the indexes
i=0
j=0
# Traverse the new string and compare it with the old string
while [ $i -lt ${#new_string} ]; do
new_char="${new_string:i:1}"
old_char="${old_string:j:1}"
- The
compare_strings
function will be used to print what was changed in function's signatures of the staged files. - The
while
loop iterates through each character of thenew_string
and compares it to the corresponding character in theold_string
.new_char
andold_char
hold the current characters from each string, based on their indexes.
if [ "$new_char" = "$old_char" ]; then
result="$result$new_char"
i=$((i+1))
j=$((j+1))
else
if [ "$new_char" != "$old_char" ] && [ ! "$new_char" = "$old_char" ]; then
result="$result${green}${new_char}${no_color}"
i=$((i+1))
else
while [ "$new_char" != "$old_char" ] && [ $j -lt ${#old_string} ]; do
result="$result${red}${old_char}${no_color}"
j=$((j+1))
old_char="${old_string:j:1}"
done
fi
fi
- If the current characters from both strings match, the character from the
new_string
is appended to theresult
without any highlighting. - The indexes
i
andj
are incremented to move to the next character in both strings. - If the current character from the
new_string
differs from theold_string
, the character fromnew_string
is highlighted in green (usinggreen
andno_color
) and added to theresult
. - The index
i
is incremented to move to the next character of thenew_string
. - If the characters don’t match and the
new_char
is not present in theold_string
, the function iterates through theold_string
until a match is found, highlighting the unmatched characters in red (indicating they have been removed). -
j
is incremented while traversing theold_string
.
while [ $j -lt ${#old_string} ]; do
result="$result${red}${old_string:j:1}${no_color}"
j=$((j+1))
done
echo -e "${result}"
- After comparing all characters in the
new_string
, if there are remaining characters in theold_string
, they are appended to theresult
highlighted in red to indicate they were removed. - Finally, the function prints the
result
, which contains the compared strings with characters highlighted to indicate additions (green) or removals (red).
3. Finding updates in function’s signature
printf "${blue}Initializing Husky${no_color}\\\\n"
REPO_ROOT=$(git rev-parse --show-toplevel)
SITE_CHANGES=$(git status -s "$REPO_ROOT" | wc -l)
printf "Detected ${yellow}$SITE_CHANGES${no_color} changes\\\\n"
if [ "$SITE_CHANGES" -gt 0 ]; then
# Check files for function signature changes
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\\\.(ts|tsx|js|jsx)$')
if [ -n "$CHANGED_FILES" ]; then
printf "${blue}Checking TypeScript files for potentially breakable changes...${no_color}\\\\n"
# Define the regex pattern and dont ask me how I get this right (hint: ends with "gpt")
REGEX="function\\\\s+[a-zA-Z_$][0-9a-zA-Z_$]*\\\\s*\\\\([^)]*\\\\)(?:\\\\s*:\\\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\\\s*(?:;|\\\\{)|[a-zA-Z_$][0-9a-zA-Z_$]*\\\\s*=\\\\s*\\\\([^)]*\\\\)(?:\\\\s*:\\\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\\\s*=>\\\\s*[^\\\\{]*?(?:;|\\\\s*)|(?:public|private|protected)?\\\\s*[a-zA-Z_$][0-9a-zA-Z_$]*\\\\s*\\\\([^)]*\\\\)(?:\\\\s*:\\\\s*[a-zA-Z_$][0-9a-zA-Z_$]*)?\\\\s*(?:;|\\\\{)"
for FILE in $CHANGED_FILES; do
if [[ -f "$REPO_ROOT/$FILE" ]]; then
printf "Checking file ${yellow}$FILE${no_color}...\\\\n"
STAGED_ADDITIONS=$(git diff --cached "$REPO_ROOT/$FILE" | grep -E "^\\\\+[^+]" | sed 's/^\\\\+//')
STAGED_REMOVALS=$(git diff --cached "$REPO_ROOT/$FILE" | grep -E "^\\\\-[^-]" | sed 's/^\\\\-//')
if [ -n "$ADDITIONS_MATCHED" ]; then
printf "${red}Signature changes detected in ${yellow}$FILE${no_color}. Showing changes:\\\\n"
compare_strings "$REMOVALS_MATCHED" "$ADDITIONS_MATCHED"
POTENTIALLY_BREAKABLE_CHANGES=1
fi
fi
done
-
REPO_ROOT
is set to the root directory of the git repository usinggit rev-parse --show-toplevel
. -
SITE_CHANGES
counts the number of changes detected in the repository usinggit status
andwc -l
(to count the lines in the output). - If changes are detected in the repository, the script looks for staged files that match the extensions
.ts
,.tsx
,.js
, or.jsx
usinggit diff --cached
. These files are stored inCHANGED_FILES
. - If there are changed files, the script informs the user and defines a
REGEX
pattern to identify function signatures, arrow functions, and class methods in TypeScript or JavaScript files. - The script then iterates over each file in
CHANGED_FILES
, checking if it exists in the repository root. -
STAGED_ADDITIONS
andSTAGED_REMOVALS
capture the added and removed lines from the staged files by filtering lines starting with+
or-
. - If there are any function signature changes detected, the
compare_strings
function is called to highlight the differences between the added and removed function signatures and sets the flagPOTENTIALLY_BREAKABLE_CHANGES
to 1 (true).
4. Check for Version Updates
if [ "$POTENTIALLY_BREAKABLE_CHANGES" -eq 1 ]; then
printf "Checking to make sure package version was updated...\\n"
if [ "$ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED" -eq 1 ]; then
VERSION_CHANGED=$(git diff -G '"version":' --cached package.json | wc -l)
if [ "$VERSION_CHANGED" -gt "0" ]; then
printf "${green}Version was updated! Continuing...${no_color}\\n"
else
printf "${red}Version was not updated :( Aborting commit.${no_color}\\n"
exit 1
fi
elif [ "$ABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED" -eq 1 ]; then
CURRENT_VERSION=$(grep -oP '"version":\\s*"\\K[0-9]+\\.[0-9]+\\.[0-9]+"' package.json | tr -d '"')
CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | cut -d'.' -f1)
STAGED_VERSION=$(git diff --cached package.json | grep -oP '"version":\\s*"\\K[0-9]+\\.[0-9]+\\.[0-9]+"' | tr -d '"')
STAGED_MAJOR=$(echo "$STAGED_VERSION" | cut -d'.' -f1)
if [ -n "$STAGED_MAJOR" ]; then
# Check if the MAJOR version has changed
if [ "$CURRENT_MAJOR" != "$STAGED_MAJOR" ]; then
printf "${green}MAJOR version was updated! Continuing...${no_color}\\n"
else
printf "${red}MAJOR version was not updated :( Aborting commit.${no_color}\\n"
fi
else
printf "${red}MAJOR version was not updated :( Aborting commit.${no_color}\\n"
exit 1
fi
fi
fi
- If potencial breakable changes are detected, the script checks for the two flags for defining when to abort the process. if
ABORT_IF_ANY_VERSION_WAS_NOT_UPDATED
is set to true, it checks whether thepackage.json
version was updated. If not, the commit is aborted with a message. - if
ABORT_IF_MAJOR_VERSION_WAS_NOT_UPDATED
is set to true, it extracts theCURRENT_VERSION
of thepackage.json
file usinggrep
. The regular expression captures the version (e.g.,1.2.3
), andtr -d '"'
removes the surrounding double quotes. - Then it extracts the
CURRENT_MAJOR
version part (e.g.,1
from1.2.3
) usingcut -d'.' -f1
, which splits the version by dots and selects the first part. - It does the same thing for the
STAGED_VERSION
andSTAGED_MAJOR
but usinggit diff --cached
to capture the differences inpackage.json
file. - Finally, it compares the current and staged major versions. If they are different, it means the MAJOR version was updated correctly, and the script prints a success message and continues. If the major versions are the same, it prints an error message and aborts the commit process with
exit 1
.
Using our Hook
To verify the hook running, change some random function’s signature like this:
Then, run the command git add --all
and commit try to commit them using git commit -m 'my hook test'
, the hook will execute and you will see the following message:
And that’s it! the commit process will be aborted and you will not be able to commit changes until you increment the MAJOR version in your package.json
file. If you do that, the hook will allow you to commit like this:
Improving the script
As much as this script can help you manage semantic versioning, it is still very rudimentary, and there is room for several improvements. Some possible enhancements include:
- Better analyzing the tested function to understand if it directly interacts with the end user of the code, increasing the certainty that changing its signature constitutes a breakable change.
- Looking for changes in API route addresses.
- Searching for changes in the contract of input and output types.
If you liked this script and would like to help improve it, feel free to make modifications. And, if you’d like, send me your changes so I can also benefit from your improvements 😂
For now, this script is hosted in a Gist, but I could create a repository where you can submit a PR, allowing us to track contributions from collaborators.
Conclusion
Integrating Husky with Git hooks allows us to automate the process of checking for potentially breaking changes in code and enforcing semantic versioning best practices. By utilizing scripts like the one demonstrated, we can identify updates in function signatures and ensure that the MAJOR version is incremented when necessary, reducing the risk of introducing breaking changes without proper versioning. This approach helps maintain a clean, organized versioning system while minimizing human error, allowing teams to collaborate more effectively and safely push code changes without compromising software stability.
Further Reading
Don’t forget to check out my article on Semantic Versioning. And if you’re curious about how to showcase your GitHub repositories on LinkedIn, this one’s for you!
If you enjoyed this article, please leave that naughty like and if you got questions drop them in the comments 🙌
Top comments (0)