Automating Azure DevOps Work Item association with Git commit hooks

Automating Azure DevOps Work Item association with Git commit hooks

In modern software development, maintaining traceability between code changes and work items is crucial for project management, compliance, and debugging.

Azure DevOps provides excellent integration between Git commits and work items, but it relies on developers remembering to include work item references in their commit messages.

This is where Git hooks, in particular the commit-msg hook comes to the rescue.

This article will show you how to implement a robust commit-msg Git hook that automatically enforces Azure DevOps work item association, either by detecting existing hash number syntax (#123) or by intelligently extracting work item numbers from branch names.

Why automate Work Item association?

Before diving into the implementation, let’s understand the benefits:

  • Consistency: Ensures every commit is linked to a work item
  • Traceability: Makes it easy to track which commits relate to specific features or bugs
  • Compliance: Helps meet organisational requirements for change tracking
  • Reporting: Enables better project metrics and allows for automatic generation of release notes

The solution: A smart commit hook

Our approach uses a commit-msg Git hook that:

  1. Checks existing commit messages for work item references (e.g., #123)
  2. Extracts work item numbers from branch names when not found in the message
  3. Automatically appends the work item reference to the commit message
  4. Prevents commits that can’t be associated with any work item

Understanding the branch name patterns

The hook supports two common branch naming conventions:

  • Prefix pattern: 123-feature-description or 456-bugfix-login-issue
  • Feature branch pattern: feature/789-new-dashboard or bugfix/101-memory-leak

The commit hook implementation

Here’s the complete commit-msg hook:

#!/bin/bash

# Get the current branch name
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)

# Get the commit message file
COMMIT_MSG_FILE=$1

# Validate commit message file
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ] || [ ! -r "$COMMIT_MSG_FILE" ]; then
    echo "Error: Commit message file is missing or not readable."
    exit 1
fi

# Read the commit message
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE" 2>/dev/null)
if [ $? -ne 0 ]; then
    echo "Error: Failed to read commit message file '$COMMIT_MSG_FILE'."
    exit 1
fi

# Check if branch name starts with a number or has a number after the last forward slash
if [[ $BRANCH_NAME =~ ^([0-9]+) || $BRANCH_NAME =~ /([0-9]+)(-[^/]+)?$ ]]; then
    # Extract the number (either from start or after last slash)
    if [[ ${BASH_REMATCH[1]} ]]; then
        WORK_ITEM_NUMBER="${BASH_REMATCH[1]}"
    else
        WORK_ITEM_NUMBER="${BASH_REMATCH[2]}"
    fi
    # Append the work item number to the commit message with a newline if not already present
    if ! grep -q "#$WORK_ITEM_NUMBER" "$COMMIT_MSG_FILE"; then
        echo -e "\n#$WORK_ITEM_NUMBER" >> "$COMMIT_MSG_FILE"
    fi
else
    # If no number is found in the branch name, check for #number in commit message
    if ! echo "$COMMIT_MSG" | grep -qE '#[0-9]+'; then
        echo "Error: Commit message must contain a work item number (e.g., #123) or branch name must start with a number or have a number after the last forward slash."
        exit 1
    fi
fi

exit 0

How It Works

Step 1: Branch analysis
The hook first examines the current branch name using git rev-parse --abbrev-ref HEAD and applies regex patterns to detect work item numbers.

Step 2: Message validation
It reads the commit message file and validates that it exists and is readable, providing clear error messages if not.

Step 3: Smart detection
Using bash regex matching, it looks for:

  • Numbers at the start of branch names: ^([0-9]+)
  • Numbers after the last forward slash: /([0-9]+)(-[^/]+)?$

Step 4: Automatic appending
If a work item number is found in the branch name but not in the commit message, it automatically appends #<number> to the message.

Step 5: Fallback validation
If no work item number can be extracted from the branch name, it ensures the commit message already contains a #<number> reference. If not, the hook exits with an error message, notifying the user that a work item must be associated with the commit.

Deployment across multiple repositories

Managing Git hooks across multiple repositories can be challenging. Here’s a deployment script that automates the process.

You provide the script with a local starting directory (typically your org’s Git workspace root) and it will traverse all directories below it and copy the commit-msg hook into the relevant .git/hooks folders.

#!/bin/bash

# Script to deploy Git hooks from the shared-git-hooks repo to all .git/hooks directories
# Usage: ./deploy-hooks.sh <start_directory> [--force|-f]

# Initialize variables
START_DIR=""
FORCE=0

# Parse arguments
while [ $# -gt 0 ]; do
    case "$1" in
        --force|-f)
            FORCE=1
            shift
            ;;
        *)
            if [ -z "$START_DIR" ]; then
                START_DIR="$1"
            else
                echo "Error: Unexpected argument '$1'"
                echo "Usage: $0 <start_directory> [--force|-f]"
                exit 1
            fi
            shift
            ;;
    esac
done

# Check if start directory is provided
if [ -z "$START_DIR" ]; then
    echo "Usage: $0 <start_directory> [--force|-f]"
    exit 1
fi

HOOKS_SRC="$(pwd)/hooks"

# Validate directories
if [ ! -d "$START_DIR" ]; then
    echo "Error: Start directory '$START_DIR' does not exist."
    exit 1
fi

if [ ! -d "$HOOKS_SRC" ]; then
    echo "Error: Hooks directory not found in current directory ($HOOKS_SRC)."
    exit 1
fi

# Find all .git directories recursively
echo "Searching for Git repositories in $START_DIR..."
find "$START_DIR" -type d -name ".git" | while read -r git_dir; do
    repo_dir=$(dirname "$git_dir")
    hooks_dir="$git_dir/hooks"
    echo "Processing repository: $repo_dir"

    # Ensure hooks directory exists
    mkdir -p "$hooks_dir"

    # Copy each hook from the hooks/ directory
    for hook in "$HOOKS_SRC"/*; do
        hook_name=$(basename "$hook")
        hook_dest="$hooks_dir/$hook_name"

        # Skip any hook that already exists unless force flag is set
        if [ "$FORCE" -eq 0 ] && [ -f "$hook_dest" ]; then
            echo "  Skipping $hook_name: already exists in $repo_dir"
            continue
        fi

        # Copy the hook and make it executable
        if cp "$hook" "$hook_dest"; then
            chmod +x "$hook_dest"
            if [ "$FORCE" -eq 1 ] && [ -f "$hook_dest" ]; then
                echo "  Overwrote $hook_name in $repo_dir (force mode)"
            else
                echo "  Installed $hook_name to $repo_dir"
            fi
        else
            echo "  Error: Failed to copy $hook_name to $repo_dir"
        fi
    done
done

echo "Hook deployment completed."
exit 0

Setting up your hook infrastructure

1. Create a shared hooks repository

mkdir shared-git-hooks
cd shared-git-hooks
mkdir hooks
# Place your commit-msg hook in the hooks/ directory
# Add the deploy-hooks.sh script to the root

Don’t forget to chmod +x deploy-hooks.sh - to allow execution permissions.

2. Copy to all repositories

# Deploy to all repos in your workspace
./deploy-hooks.sh ~/workspace

# Force update existing hooks
./deploy-hooks.sh ~/workspace --force

3. Test your setup

Create a test branch and commit:

# Branch with work item number
git checkout -b 123-test-feature
git add .
git commit -m "Add new feature"
# Result: "Add new feature\n\n#123"

# Branch without work item number but commit with reference
git checkout -b feature-branch
git add .
git commit -m "Fix bug #456"
# Result: Commit succeeds with existing #456 reference

Best practices and tips

Branch naming conventions
Establish consistent patterns across your team:

  • <work-item>-<description>: 1234-implement-login
  • <type>/<work-item>-<description>: feature/1234-implement-login

Error handling
The hook provides clear error messages when work items can’t be associated, helping developers understand what went wrong.

Customisation
Modify the regex patterns to match your team’s specific branch naming conventions. The current implementation is flexible enough for most common patterns.

Troubleshooting common issues

Hook not executing
Ensure the hook file has executable permissions: chmod +x .git/hooks/commit-msg

Regex not matching
Test your branch names against the patterns using: [[ "your-branch-name" =~ ^([0-9]+) ]] && echo "Match!"

File permission errors
The deployment script automatically sets executable permissions on the hooks, but verify manually if issues persist.

Conclusion

Implementing this Git hook system transforms work item association from a manual, error-prone process into an automatic, reliable one. Your team will benefit from better traceability, improved compliance, and reduced cognitive overhead during development.

The combination of intelligent branch name parsing and fallback validation ensures that every commit is properly associated with Azure DevOps work items, while the deployment script makes it easy to maintain consistency across all your repositories.

Start with a pilot project, gather feedback, and then roll out across your organisation. Your future self (and your project managers) will thank you for the improved traceability and reduced manual effort.