Wrote on

Automating the Git commit message prefix

If you’re a programmer you probably love automating the daily tasks you do daily, don’t you? Well, if you don’t you probably should.

The current project I work on has a specific Git Branching model that follows the Jira tasks number. In a nutshell, here are the requirements: when I tackle a story/feature task, it has a number, let’s say TASK-1. You move the ticket into in-progress and you also create a first sub-task called “Implement” or “Fix” or something similar.

If you’re like me and like to see the process visually, here’s how it looks:

In addition to the branching model above, the commit messages must follow the following rule for the prefix: MainTask/SubTask: [commit message]. For example for the task in the flow above, the commit message can be TASK-1/TASK-2: Write the first draft.

For me, this was the most cumbersome thing, mainly because I want to commit often, not have a single commit with all the finished code, so whenever I did a commit I had to look up the story number and the sub-task I am working on.

Let’s see how we can automate this prefix.

Part 1 – Git Hooks and config

Git is very extendible: you can have various custom Key-Value configurations for each local repository or for the global Git system itself. We’re gonna be using some custom Git configurations to suit our automation.

Configuration keyWhat is this configuration used for
ticketNameWe’re going to use this configuration to hold the current ticket name/title so if we commit using a blank message, the ticket name will be automatically added after the prefix.
storyBranchThis one will be assigned to the sub-task of the story task to specify which branch this sub-branch belongs to.

The way we set these configurations for each branch is usually via the command-line interface:

git config branch.TASK-1.ticketName "This is the ticket name from Jira"
git config branch.TASK-2.ticketName "This is the ticket name from Jira"
git config branch.TASK-2.storyBranch "TASK-1"

We can also do this manually if we edit the file that is located in the file .git/config inside the repository root.

The file looks similar to this after we’ve run the commands above:

[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
[branch "TASK-1"]
	ticketName = This is the ticket name from Jira
[branch "TASK-2"]
	ticketName = This is the ticket name from Jira
	storyBranch = TASK-1

So each custom key we set is stored in the .git/config file, no other magical place, this is good to know.

We know have the basic custom configuration we want to be set, we can further use those values in a custom Git hook called prepare-commit-msg. This hook is called automatically when you commit, and prepares the message, as its name says.

This is where we get to Bash scripting. I am not the best in Bash, but Google has always been my friend when I had to deal with Bash.

I like to keep my git hook file in a separate folder in my home folder (~) mostly because maybe not all team members of the project will want to use my automation and my way of working with it. So the first thing we have to do is to create a new folder somewhere on the PC and set the git hooks path for the repository to point to the newly created folder.

mkdir ~/.githooks
git config --local core.hooksPath ~/.githooks/

Now if we look inside our Git config file we also have a new key that we’ve set with the above command.

[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
	hooksPath = /Users/filipac/.githooks/

There are many available Git hooks but we’re going to focus on a single one now: prepare-commit-msg. You can read more about Git hooks here.

So… we need to create our first Git hook. Exciting, isn’t it? There are two things, ok maybe three, that we need to remember:

  • Hook file name has to match the git hook name (in our case prepare-commit-msg)
  • The file is a Bash script (most of the times, I **THINK** you can write in other languages too)
  • The file must be executable (+x mode)

So let’s go ahead and make our hook. It is important to note that this hook will apply to every repository you want to, so you only write the Bash script once, and then on every repository you run `git config –local core.hooksPath ~/.githooks/` this script will run/apply. Don’t worry, we need to save time with automation not waste more time writing Bash.

cd ~/.githooks
touch prepare-commit-msg
chmod +x prepare-commit-msg
# Now let's open this in a code editor, you can do it in Nano, Vim or any other. I choose VS Code.
code prepare-commit-msg

Ok, so now we have an empty executable file in our code editor, and we’re ready to do some bashing. The first thing we need to do is set the shebang and tell the terminal this is a Bash script. Then we need to define 3 initial variables: currentBranchName, storyBranch and ticketName – pretty self-explanatory variables.

#!/usr/bin/env bash

currentBranchName=$(git rev-parse --abbrev-ref HEAD)

storyBranch=""

storyBranchConfig=$(git config branch.$currentBranchName.storyBranch)
if [ -n "$storyBranchConfig" ]; then
    storyBranch="$storyBranchConfig"
fi

ticketName=""
tn=$(git config branch.$currentBranchName.ticketName)
if [ -n "$tn" ]; then
    ticketName="$tn"
fi

There’s one special case when we don’t want to do anything: when we do squash or fixup commits. Let’s exit the script early if this is the case.

#!/usr/bin/env bash

currentBranchName=$(git rev-parse --abbrev-ref HEAD)

storyBranch=""

storyBranchConfig=$(git config branch.$currentBranchName.storyBranch)
if [ -n "$storyBranchConfig" ]; then
    storyBranch="$storyBranchConfig"
fi

ticketName=""
tn=$(git config branch.$currentBranchName.ticketName)
if [ -n "$tn" ]; then
    ticketName="$tn"
fi

# do not prefix fixup! squash! commits
if cat "$1" | grep -E -i "^(fixup|squash)!" > /dev/null; then
    exit 0
fi

The next step is to write a helper function to use later in the script. Basically how the hook works is by writing the prefix of the message in a random file inside the git folder and we get this random file name as the first ($1) parameter, so we don’t need to know its name, Git is friendly and gives it to us.

The function we need has 3 parameters: the filename I mentioned above, the prefix we want to add and the commitSource parameter which tells us from where is the user trying to commit and can be: message (if a -m or -F option was given); template (if a -t option was given or the configuration option commit.template is set); merge (if the commit is a merge or a .git/MERGE_MSG file exists); squash (if a .git/SQUASH_MSG file exists); or commit, followed by a commit object name (if a -c-C or --amend option was given).

Then we’re going to check if we don’t have a commit source or a message given for the commit, we’re going to simply add the prefix alone, otherwise, we will add the prefix and the message, joined by a double colon. Let’s add the function to our script.

#!/usr/bin/env bash

currentBranchName=$(git rev-parse --abbrev-ref HEAD)

storyBranch=""

storyBranchConfig=$(git config branch.$currentBranchName.storyBranch)
if [ -n "$storyBranchConfig" ]; then
    storyBranch="$storyBranchConfig"
fi

ticketName=""
tn=$(git config branch.$currentBranchName.ticketName)
if [ -n "$tn" ]; then
    ticketName="$tn"
fi

# do not prefix fixup! squash! commits
if cat "$1" | grep -E -i "^(fixup|squash)!" > /dev/null; then
    exit 0
fi

function prefixCommitMessage {
    filename=$1
    prefix=$2
    commitSource=$3

    originalmessage=$(cat "$filename")

    if [[ $originalmessage == $prefix* ]]
        then
        exit 0
    fi

    if [[ -z "$commitSource" ]]
    then
        echo "$prefix" > "$filename"
    else
        if [[ -z $originalmessage ]]; then
            echo "$prefix" > "$filename"
        else
            echo "$prefix: $originalmessage" > "$filename"
        fi
    fi
}

Now we’re going to write the final part of the script. We will wrap the whole next part in a BIG if statement: Only do our thing IF the currentBranchName variable matches TASK-0 so starts with TASK and is followed by a dash and a number. We don’t want to do crazy stuff for special branches, sometimes we don’t follow the convention for hot-fix branches or stuff like that. I am going to show you the final part of the hook script then I will tell you what’s happening there.

#!/usr/bin/env bash

currentBranchName=$(git rev-parse --abbrev-ref HEAD)

storyBranch=""

storyBranchConfig=$(git config branch.$currentBranchName.storyBranch)
if [ -n "$storyBranchConfig" ]; then
    storyBranch="$storyBranchConfig"
fi

ticketName=""
tn=$(git config branch.$currentBranchName.ticketName)
if [ -n "$tn" ]; then
    ticketName="$tn"
fi

# do not prefix fixup! squash! commits
if cat "$1" | grep -E -i "^(fixup|squash)!" > /dev/null; then
    exit 0
fi

function prefixCommitMessage {
    filename=$1
    prefix=$2
    commitSource=$3

    originalmessage=$(cat "$filename")

    if [[ $originalmessage == $prefix* ]]
        then
        exit 0
    fi

    if [[ -z "$commitSource" ]]
    then
        echo "$prefix" > "$filename"
    else
        if [[ -z $originalmessage ]]; then
            echo "$prefix" > "$filename"
        else
            echo "$prefix: $originalmessage" > "$filename"
        fi
    fi
}

# Branch name is like: TASK-123
if echo "$currentBranchName" | grep -E -i "^[a-z]{2,}-[0-9]+" > /dev/null; then
    subTask=$(echo "$currentBranchName" | awk 'BEGIN{ FS="-"; OFS=FS } { print toupper($1),$2 }')

    if [[ "$currentBranchName" == "TASK"* ]] && [[ "$storyBranch" == "TASK"* ]]; then
        pr="$(echo "$storyBranch")/$(echo "$subTask")"
        originalmessage=$(cat "$1")
        if [[ -z $originalmessage ]]; then
            if [ -n "$tn" ]; then
                pr="$pr: $ticketName"
            fi
        fi
        prefixCommitMessage "$1" "$(echo "$pr")" "$2"
    elif [[ "$currentBranchName" == "TASK"* ]]; then
        prefixCommitMessage "$1" "$(echo "$currentBranchName")" "$2"
    fi
    exit
fi

First, we have that big if statement we talked about, currentBranchName has to be like TASK-number.

Then we have two sub-cases.

Case 1 – We are on a sub-task branch, so both currentBranchName and storyBranch variables start with TASK. In this case, the prefix will be "$(echo "$storyBranch")/$(echo "$subTask")" so something like TASK-1/TASK-2. Then we will check if we have a commit original message, if not we’re going to append the ticketName.

Case 2 – We are on a story task, so the main branch like TASK-1. Sometimes it does not make sense to add a new sub-task to our story and instead, we want to do changes to the story branch directly. Most of the time this happens to me when I need to do small changes that come from the Project Manager testing the feature and the code I add does not need code review. In this case, we still check that the currentBranchName starts with TASK and we simply prefix the commit message with TASK-1:

Simple and easy, eh? The whole script of the hook is under 100 lines of code.

Now our script is ready to do its magic. Let’s test it.

If you now write the following commands in your terminal, you will be able to see the magic we just did:

git checkout main
# optional: pull the source branch before creating a new branch from it
# git pull --rebase
git checkout -b "TASK-1" # our story branch
git checkout -b "TASK-2" # our implement/work branch
git config branch.TASK-1.ticketName "This is the ticket name from Jira"
git config branch.TASK-2.ticketName "This is the ticket name from Jira"
git config branch.TASK-2.storyBranch "TASK-1"
touch TEST && git add TEST
git commit -m "Add a new file"

Now take a look in you favorite Git client or type git log and you will see that we got our prefix to the message: TASK-1/TASK-2: Add a new file.

What git log shows us now
How it looks in a git client

Ok, so now our hook is working and we can rely on it. The final step is to automate how we run the commands above easier.

Part 2 – Commands alias in terminal

Remembering all those commands above is hard, let alone writing those all the time we start a new story.

What if we could create a simple story command on our command-line interface?

Well… we can! Open up your shell config (.zshrc, .bashrc etc), and create the story alias as following (the script is commented so you can understand what it does.

story() {
  # first argument is the Story/Subtask string. Split it by / and asign to separate variables
  str=$1
  parts=(${(@s:/:)str})
  story=${parts[1]} # main story name and number
  work=${parts[2]} # subtask name and number
  # second argument is the branch name, but can be omitted
  storyName=$2
  # checkout main first and make sure we're up to date with upstream
  git checkout main
  git pull --rebase
  # create our story branch
  git checkout -b "$story"
  # set upstream and push it to origin remote
  git push --set-upstream origin "$story"
  # if we have a storyName, set that to git config key for this branch
  if [ -n "$storyName" ]; then
    git config branch.$story.storyName "$storyName"
  fi
  # create our sub-task branch
  git checkout -b "$work"
  # set upstream and push it to origin remote
  git push --set-upstream origin "$work"
  # if we have a storyName, set that to git config key for this branch
  if [ -n "$storyName" ]; then
    git config branch.$work.storyName "$storyName"
  fi
  git config branch.$work.storyBranch "$story"
  echo "Created branch story $story and you are now on subtask $work"
}

After you added this to your shell config, restart your terminal or reload the config (source ~/zshrc) and you will be able to write a simple command to do the whole magic:

story "TASK-3/TASK-4" "Add a button"

Part 3,4,5,etc – Possibility to extend and improvise

Since we are using low-level building blocks like Git config and Git hooks, we can enjoy the funtionality we built in almost any Git client or IDE. Visual Studio Code, PhpStorm, you name it… anything that uses Git will now run our hook and set the prefix.

So you are only limited by your imagination.

For example, today I wrote a small VSCode extension for me to do all those commands above from the editor itself so I don’t need to jump to a terminal to type the command alias we created. Here’s how it looks:

  • I can start a new story or do common tasks like open a Merge Request or set the Git Hooks config per repository
  • I can see in status bar the Story Branch and the Sub-Task branch
  • In addition to the script alias we did above, the extension also creates a stash before switching the branch and later applies the stash and deletes it

I will not publish the extension for now because it is too tailored to my needs (pre-configured Gitlab url, biased prefix for the project I work on, etc). Let me know if you are interested in this as well and I will polish and open source it.

The idea is that you can use any IDE and build a custom extension that sets those Git configs and the system will work just ok.

Season finale

That would be all for this tutorial, if you have questions let me know in the comments section below and I will try to help and guide you.

I really hope that this tutorial will help at least one person, you are free to give kudos and thanks in the comments section below, don’t be shy to be thankful. Spent quite a few hours writing this.

Or, if you insist, buy me a coffee. I only accept $EGLD crypto because it is the best one.

Thank you for reading. Peace.

Leave a Reply

Your email address will not be published. Required fields are marked *

Acest site folosește Akismet pentru a reduce spamul. Află cum sunt procesate datele comentariilor tale.

Copyright © 2024 all rights
are not
reserved. Do whatever you want, it's a free country.
Guess it's obvious, but the theme is created by myself with Tailwind CSS. You can find the source code here.
I still use WordPress 🧡. The theme is custom Laravel though 😎.