Build Custom Git Subcommands: Extend the Git CLI with Bash and Python

Git tutorial - IT technology blog
Git tutorial - IT technology blog

Context: A 2 AM Outage and the Cost of Absentmindedness

2:15 AM. A payment gateway timeout left over 200 transactions hanging. The entire team scrambled to their laptops to ship a hotfix. Bleary-eyed and exhausted, I botched a cherry-pick command and casually followed it up with git push --force. The result: four crucial commits on the shared branch vanished into thin air. The team spent nearly three hours reverting and restoring the lost history. That night taught me an unforgettable lesson: never entrust system safety to human alertness in the middle of the night.

Many developers rely heavily on git alias inside ~/.gitconfig. That works well for simple shortcuts like co = checkout or st = status. But as requirements grow more complex, aliases quickly hit their limits. You cannot parse flags flexibly, validate input properly, provide a proper --help screen, and cramming dozens of lines of shell logic into a single configuration file is a maintenance nightmare.

Git solved this problem long ago through Custom Git Subcommands. The mechanism is elegantly simple: any executable file found in your PATH environment variable named in the format git-<command_name> becomes a valid Git subcommand. When you type git safe-push, Git automatically invokes git-safe-push and forwards all arguments directly to it.

Setup: Resolution Mechanics and Your First Subcommand

Git’s command resolution is straightforward. Whenever you enter a command not built into Git’s core (such as git nuke), Git scans the directories listed in your $PATH to locate an executable named git-nuke.

Step 1: Set Up a Dedicated Directory for Personal Scripts

Avoid dumping personal scripts directly into system directories like /usr/bin or /bin. Keep them cleanly separated within your user home directory for easy backups and machine migrations:

mkdir -p ~/.local/bin

# Add the path to ~/.bashrc or ~/.zshrc
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

Step 2: Write a Script to Prevent Accidental Branch Overwrites

Create the file ~/.local/bin/git-safe-push. This script uses the safer --force-with-lease flag and completely blocks force-pushing against critical branches:

cat << 'EOF' > ~/.local/bin/git-safe-push
#!/usr/bin/env bash
set -euo pipefail

CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || true)

if [ -z "$CURRENT_BRANCH" ]; then
  echo "[ERROR] Currently in a detached HEAD state. Processing aborted." >&2
  exit 1
fi

# List of branches protected from force-pushing
PROTECTED_BRANCHES=("main" "master" "production" "release")

for b in "${PROTECTED_BRANCHES[@]}"; do
  if [ "$CURRENT_BRANCH" = "$b" ]; then
    echo "[BLOCKED] You are currently on protected branch: '$CURRENT_BRANCH'!" >&2
    echo "Using safe-push on this branch is strictly prohibited." >&2
    exit 2
  fi
done

echo "=> Safely pushing branch: $CURRENT_BRANCH"
git push origin "$CURRENT_BRANCH" --force-with-lease
EOF

chmod +x ~/.local/bin/git-safe-push

Now, you can run git safe-push from inside any repository on your machine.

Advanced: Handling Complex Logic with Python

Shell scripts are great for quick, simple tasks. However, when you need to parse structured logs, run complex regex validations, or call external APIs, Python is much more readable and maintainable thanks to standard modules like argparse and subprocess.

Building a Pre-Release Audit Tool: git-audit

Create the file ~/.local/bin/git-audit. This script automatically audits recent commits to catch missing Jira Ticket IDs and alerts you if sensitive credential files were accidentally staged into HEAD:

#!/usr/bin/env python3
import argparse
import subprocess
import sys
import re

def run_cmd(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"[ERROR] Command failed: {cmd}\n{result.stderr}", file=sys.stderr)
        sys.exit(result.returncode)
    return result.stdout.strip()

def main():
    parser = argparse.ArgumentParser(
        prog="git audit",
        description="Validate commit message formats and scan for sensitive files before deploying."
    )
    parser.add_argument("-n", "--count", type=int, default=5, help="Number of recent commits to check (default: 5)")
    parser.add_argument("--strict", action="store_true", help="Fail the run if any commit is missing a Jira Ticket ID")
    args = parser.parse_args()

    print(f"[*] Checking the last {args.count} commits...")
    log_output = run_cmd(f"git log -n {args.count} --pretty=format:'%h|%an|%s'")
    
    if not log_output:
        print("[!] No commits found.")
        return

    has_warning = False
    for line in log_output.splitlines():
        sha, author, msg = line.split("|", 2)
        # Verify Ticket ID prefix in commit message (e.g. PROJ-1024)
        ticket_pattern = r"^[A-Z]{2,10}-[0-9]+"
        if not re.search(ticket_pattern, msg):
            print(f"  [WARNING] Commit {sha} ({author}) is missing a Ticket ID: '{msg}'")
            has_warning = True
        else:
            print(f"  [OK] {sha} - {msg}")

    # Check for sensitive secret files accidentally committed in HEAD
    changed_files = run_cmd("git diff-tree --no-commit-id --name-only -r HEAD")
    sensitive_patterns = [".env", "credentials.json", "id_rsa", ".pem"]
    for f in changed_files.splitlines():
        if any(pat in f for pat in sensitive_patterns):
            print(f"\n[HIGH RISK] Sensitive file detected in commit HEAD: {f}", file=sys.stderr)
            sys.exit(3)

    if has_warning and args.strict:
        print("\n[FAILED] Strict mode enabled: Commit message format violation found.", file=sys.stderr)
        sys.exit(1)

    print("\n=> Audit completed, source code is ready!")

if __name__ == "__main__":
    main()

Remember to grant execution permissions to the script:

chmod +x ~/.local/bin/git-audit

Testing & Debugging: Ensuring Your Script Runs Smoothly

Writing the script is only half the battle; validating its reliability before integrating it into daily workflows is essential.

1. Verify Git Detects Your Subcommand

Instead of manually inspecting folders, use Git’s built-in flag to list all discovered external subcommands:

git --list-cmds=others | grep -E "(safe-push|audit)"

2. Check Exit Codes

A well-behaved subcommand must return meaningful exit codes. This allows other shell scripts or CI/CD pipelines to catch failures reliably and abort execution when needed:

# Verify the Python script help menu
git audit --help

# Run a test in strict mode and inspect the exit code
git audit -n 3 --strict
echo "Exit Code: $?"

A non-zero exit code confirms that the script correctly intercepted the rule violation.

3. Troubleshooting the “is not a git command” Error

  • Missing execute permissions: Run ls -l $(which git-safe-push) to ensure the file has -rwxr-xr-x permissions.
  • Incorrect Shebang: Always use #!/usr/bin/env bash or #!/usr/bin/env python3. This ensures scripts run reliably across Linux and macOS environments without relying on hardcoded paths like #!/bin/bash.
  • Outdated shell PATH cache: If you just added a script and Git fails to discover it, run hash -r (in Bash) or rehash (in Zsh) to refresh the command cache.
Share: