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 key | What is this configuration used for |
---|---|
ticketName | We’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. |
storyBranch | This 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.
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