Tutorials & Guides
How to Build a Local Pre-Commit Hook to Auto-Generate Unit Tests for Git Diffs Using Claude 3.5 Sonnet and Python
Stop skipping the boring stuff. Learn how to write a custom local git hook that intercepts your staging area, extracts your modified Python functions, and asks Claude to build your unit tests automatically.
Updated 10/5/2026
Why We Skip Writing Tests (And How to Fix It)
Let’s be entirely honest: writing unit tests is the software development equivalent of eating your broccoli. You know it is objectively good for you, you know it prevents disaster down the line, but when you are in the flow of shipping a feature, writing boilerplate assertion blocks is the first thing that gets sidelined.
Instead of lecturing you on test-driven development, let’s automate the boring part right at the source.
By leveraging a local Git pre-commit hook, we can intercept your code the moment you stage it, isolate the specific functions you modified, and run them through Claude 3.5 Sonnet to generate ready-to-run tests before your commit even lands in your history. What makes this workflow tick is its hyper-specificity: Claude doesn’t need to look at your entire repository; it only needs the context of your staged modifications.
In this step-by-step guide, we’ll build a lightweight Python script that acts as a custom Git hook. It parses your current git diff, identifies new or modified Python functions, sends them to Claude, and appends the generated test suites to your testing directory.
The Architecture of an AI-Powered Git Hook
We want this process to be seamless and fast. We don’t want to wait minutes for a huge test suite run, nor do we want to burn through our daily Anthropic API limits. Here is how our pipeline will operate:
- The Hook Trigger: Git executes
.git/hooks/pre-commitwhen you rungit commit. - Diff Parsing: Python inspects the staged changes using
git diff --cached. - Extraction: The script isolates newly added or refactored Python functions.
- LLM Prompting: The script passes the isolated diff and file context to Claude, asking for corresponding pytest unit tests.
- Writing to Disk: The hook saves the new tests into a mirrored
tests/directory structure, then allows the commit to complete.
Let’s start building.
Step 1: Setting up the Git Hook Wrapper
First, navigate to your local Git repository. Inside your repository, Git keeps its internal hooks in the hidden .git/hooks/ directory. By default, this directory is populated with sample files.
Create a new file named pre-commit inside .git/hooks/ (make sure it has no file extension):
`bash
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
`
Open this file in your editor and add a bash shebang that redirects the execution to our Python script. We do this because writing complex text-parsing and API logic in raw Bash is a fast track to a headache.
`bash
#!/usr/bin/env bash
Run our AI test generation script python3 scripts/generate_tests_hook.py
Capture the exit code of our script RESULT=$?
if [ $RESULT -ne 0 ]; then echo "[AI-Test-Hook] Failed to generate tests or returned an error. Commit blocked." exit 1 fi
exit 0
`
Step 2: Extracting Staged Functions with Python
Now, let’s create the script that does the heavy lifting. Create a directory named scripts in your project root, and place a file named generate_tests_hook.py inside it.
We need this script to query Git for staged modifications. We will target only .py files to keep the scope clean. Here is the setup code to grab the staged diffs:
`python
import subprocess
import sys
import os
from anthropic import Anthropic
def get_staged_diffs():
"""Retrieves the staged changes for Python files only."""
try:
# Run git diff for staged files
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True,
text=True,
check=True
)
staged_files = result.stdout.strip().split('\n')
py_files = [f for f in staged_files if f.endswith('.py') and os.path.exists(f)]
return py_files
except subprocess.CalledProcessError as e:
print(f"Error running git command: {e}")
sys.exit(1)
`
Once we have our modified files, we want to capture the specific changes so we don’t send empty context to the LLM. Let's write a function to pull the actual staged diff content for each modified file:
`python
def get_file_diff(file_path):
"""Gets the exact staged diff for a specific file."""
result = subprocess.run(
["git", "diff", "--cached", file_path],
capture_output=True,
text=True,
check=True
)
return result.stdout
`
Step 3: Structuring the Prompt for Claude
To ensure we receive clean Python code back from the model without conversational filler, we must design a highly structured system prompt. We want to construct our prompt using precise prompt engineering principles so that Claude outputs pure, runnable code.
Let’s write the function that interfaces with the Anthropic API. Before running this, make sure you have your API key set in your environment: export ANTHROPIC_API_KEY="your_key_here".
`python
def generate_unit_tests(file_path, diff_content, file_content):
client = Anthropic()
system_prompt = (
"You are an elite Python QA engineer. Your sole task is to write clean, comprehensive pytest unit tests "
"for the code modifications presented in the git diff. "
"Provide raw, valid Python code only. Do not include markdown code block backticks (like `python), "
"do not write introductory or concluding text, and do not explain your design choices. "
"Mock external API dependencies, databases, or file system access using unittest.mock."
)
user_prompt = f""" Target File: {file_path}
Full Current File Content: ----- {file_content} -----
Staged Git Diff for this file: ----- {diff_content} -----
Generate a matching test suite using pytest. Ensure you test edge cases and handle exceptions where applicable. """
try:
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=2048,
temperature=0.2,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}]
)
return message.content[0].text
except Exception as e:
print(f"API Error calling Claude: {e}")
# If you run into issues with your account limits, check https://claude-support.com
return None
`
Step 4: Writing and Merging Tests to Disk
If you modify app/utils.py, the script should automatically write or append tests to tests/test_app_utils.py. We need to handle this directory mirroring dynamically.
`python
def write_test_file(original_file_path, test_code):
if not test_code.strip():
return
Generate a matching path inside tests/ dir_name = os.path.dirname(original_file_path) base_name = os.path.basename(original_file_path) test_file_name = f"test_{base_name}" # Replicate structural directories if they do not exist target_dir = os.path.join("tests", dir_name) os.makedirs(target_dir, exist_ok=True) target_file = os.path.join(target_dir, test_file_name) # If test file exists, let's append safely or prompt the builder. # For this automation, we will overwrite or create fresh to avoid syntax conflicts. with open(target_file, "w") as f: f.write(test_code) print(f"[AI-Test-Hook] Successfully wrote tests to {target_file}") # Stage the new test file so it is included in the current commit automatically subprocess.run(["git", "add", target_file]) ```
Step 5: Tying it All Together
Here is the main orchestration loop that brings these steps into a unified system check. We’ll loop through every staged .py file, check if there is an active diff, run our generator, and seamlessly stage the new testing assets.
`python
def main():
py_files = get_staged_diffs()
if not py_files:
print("[AI-Test-Hook] No Python files staged. Skipping test generation.")
sys.exit(0)
print(f"[AI-Test-Hook] Found staged files: {', '.join(py_files)}")
for file_path in py_files:
diff = get_file_diff(file_path)
if not diff.strip():
continue
with open(file_path, "r") as f:
file_content = f.read()
print(f"[AI-Test-Hook] Analysing changes and drafting tests for {file_path}...")
test_code = generate_unit_tests(file_path, diff, file_content)
if test_code:
write_test_file(file_path, test_code)
sys.exit(0)
if __name__ == "__main__":
main()
`
Testing Your New Automagic Hook
To verify everything is configured correctly, write a small math or utility module in your repository:
`python
# src/calculator.py
def calculate_compound_interest(principal, rate, time, compound_periods):
"""Calculates compound interest on a principal amount."""
if principal < 0 or rate < 0 or time < 0:
raise ValueError("Financial inputs must be positive values.")
return principal (1 + rate / compound_periods) (compound_periods time)
`
Stage the file using git add src/calculator.py and run your commit:
`bash
git commit -m "feat: added compound interest utility"
`
Our pre-commit hook will intercept the process, extract the function signature, call Claude 3.5 Sonnet to construct a clean, parameterised test suite in tests/src/test_calculator.py, and automatically stage it into the same commit block.
If you run into issues, remember to double-check that your Anthropic API Key is exported in the active shell context where you execute your git commits. For API-level issues or credit limits, check out the official Anthropic Support Hub.
With this tool in your toolkit, you no longer have any excuses for shipping untested code. Now get back to shipping, and let Claude handle the coverage metrics.
Keep going
Build something with the prompt generator, decode the jargon in the glossary, or compare the tools on our platform deep-dives.