Nobody enjoys writing changelogs by hand. You tag a release, scroll through dozens of commits, try to remember what actually matters to users, and end up with a bullet list thatβs either too vague or too detailed. In this tutorial, youβll build a Python script that reads your git log between two tags, groups commits by type, sends them to Ollama for summarization, and outputs a clean markdown changelog. No API keys, everything local.
If you havenβt set up Ollama yet, start with our complete Ollama guide.
How It Works
The script follows a simple pipeline:
- Fetch all commits between two git tags using
git log - Parse each commit and group by conventional commit type (
feat,fix,docs, etc.) - Send each group to Ollama with a prompt asking for a human-readable summary
- Combine the summaries into a formatted markdown changelog
- Write the result to a file or print to stdout
This pairs well with the AI commit message generator β if your commits already follow conventional format, the grouping step becomes almost automatic.
Prerequisites
- Python 3.8+
- Ollama running locally
- A git repo with at least two tags
Pull a model if you havenβt already:
ollama pull qwen2.5-coder:7b
The Complete Script
Create a file called changelog_gen.py:
#!/usr/bin/env python3
"""Generate a markdown changelog between two git tags using Ollama."""
import subprocess
import sys
import json
import re
import urllib.request
from collections import defaultdict
from datetime import datetime
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "qwen2.5-coder:7b"
TYPE_LABELS = {
"feat": "π Features",
"fix": "π Bug Fixes",
"docs": "π Documentation",
"refactor": "β»οΈ Refactoring",
"perf": "β‘ Performance",
"test": "π§ͺ Tests",
"build": "π¦ Build",
"ci": "π§ CI/CD",
"chore": "π Chores",
"style": "π
Style",
}
SUMMARIZE_PROMPT = """Summarize these git commits into changelog entries for a {category} section.
Rules:
- Write 1 bullet point per logical change (merge related commits)
- Start each bullet with a verb in past tense (Added, Fixed, Updated, etc.)
- Keep each bullet under 100 characters
- Focus on what changed from a user/developer perspective
- Output ONLY the bullet points, no headers or extra text
Commits:
{commits}"""
def get_tags():
"""Return list of tags sorted by version, most recent first."""
result = subprocess.run(
["git", "tag", "--sort=-v:refname"],
capture_output=True, text=True
)
return [t for t in result.stdout.strip().split("\n") if t]
def get_commits_between(tag_from, tag_to):
"""Get commits between two tags as a list of (hash, message) tuples."""
result = subprocess.run(
["git", "log", f"{tag_from}..{tag_to}", "--pretty=format:%h|%s"],
capture_output=True, text=True
)
commits = []
for line in result.stdout.strip().split("\n"):
if "|" in line:
hash_val, message = line.split("|", 1)
commits.append((hash_val.strip(), message.strip()))
return commits
def group_commits(commits):
"""Group commits by conventional commit type."""
grouped = defaultdict(list)
pattern = re.compile(r"^(\w+)(?:\(.+?\))?:\s*(.+)")
for hash_val, message in commits:
match = pattern.match(message)
if match:
commit_type = match.group(1).lower()
grouped[commit_type].append(f"{hash_val} {message}")
else:
grouped["other"].append(f"{hash_val} {message}")
return dict(grouped)
def summarize_group(category, commits):
"""Send a group of commits to Ollama for summarization."""
commits_text = "\n".join(commits)
# Skip AI for very small groups
if len(commits) <= 2:
bullets = []
for c in commits:
# Strip hash and type prefix for clean output
msg = re.sub(r"^\w+ \w+(\(.+?\))?:\s*", "", c)
bullets.append(f"- {msg[0].upper()}{msg[1:]}")
return "\n".join(bullets)
payload = json.dumps({
"model": MODEL,
"prompt": SUMMARIZE_PROMPT.format(
category=category, commits=commits_text
),
"stream": False,
"options": {"temperature": 0.3, "num_predict": 500}
}).encode()
req = urllib.request.Request(
OLLAMA_URL,
data=payload,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=60) as resp:
response = json.loads(resp.read())["response"].strip()
# Ensure each line starts with a bullet
lines = []
for line in response.split("\n"):
line = line.strip()
if line and not line.startswith("-"):
line = f"- {line}"
if line:
lines.append(line)
return "\n".join(lines)
def get_tag_date(tag):
"""Get the date of a tag."""
result = subprocess.run(
["git", "log", "-1", "--format=%ai", tag],
capture_output=True, text=True
)
date_str = result.stdout.strip()
if date_str:
return datetime.fromisoformat(date_str).strftime("%Y-%m-%d")
return datetime.now().strftime("%Y-%m-%d")
def generate_changelog(tag_from, tag_to):
"""Generate a full markdown changelog between two tags."""
commits = get_commits_between(tag_from, tag_to)
if not commits:
return f"No commits found between {tag_from} and {tag_to}."
grouped = group_commits(commits)
date = get_tag_date(tag_to)
sections = []
sections.append(f"## [{tag_to}] β {date}\n")
# Process known types first, in order
for type_key, label in TYPE_LABELS.items():
if type_key in grouped:
summary = summarize_group(label, grouped[type_key])
sections.append(f"### {label}\n\n{summary}\n")
# Handle uncategorized commits
if "other" in grouped:
summary = summarize_group("Other Changes", grouped["other"])
sections.append(f"### π Other\n\n{summary}\n")
return "\n".join(sections)
def main():
import argparse
parser = argparse.ArgumentParser(
description="Generate AI-powered changelog from git tags"
)
parser.add_argument("--from", dest="tag_from",
help="Start tag (older)")
parser.add_argument("--to", dest="tag_to",
help="End tag (newer)")
parser.add_argument("--last", type=int, default=0,
help="Generate for last N releases")
parser.add_argument("-o", "--output",
help="Output file (default: stdout)")
parser.add_argument("--model", default=MODEL,
help=f"Ollama model (default: {MODEL})")
args = parser.parse_args()
global MODEL
MODEL = args.model
tags = get_tags()
if len(tags) < 2:
print("Need at least 2 tags to generate a changelog.",
file=sys.stderr)
sys.exit(1)
changelog_parts = []
changelog_parts.append("# Changelog\n")
if args.last > 0:
# Generate for last N releases
pairs = list(zip(tags[1:], tags))[:args.last]
for tag_from, tag_to in pairs:
print(f"Processing {tag_from}..{tag_to}...",
file=sys.stderr)
changelog_parts.append(
generate_changelog(tag_from, tag_to)
)
elif args.tag_from and args.tag_to:
changelog_parts.append(
generate_changelog(args.tag_from, args.tag_to)
)
else:
# Default: latest two tags
changelog_parts.append(
generate_changelog(tags[1], tags[0])
)
result = "\n".join(changelog_parts)
if args.output:
with open(args.output, "w") as f:
f.write(result)
print(f"Changelog written to {args.output}",
file=sys.stderr)
else:
print(result)
if __name__ == "__main__":
main()
Zero external dependencies β just Pythonβs standard library.
Usage
Generate a changelog between two specific tags:
python3 changelog_gen.py --from v1.2.0 --to v1.3.0
Generate the last 3 releases at once:
python3 changelog_gen.py --last 3 -o CHANGELOG.md
Use a different model:
python3 changelog_gen.py --last 1 --model llama3.1:8b
Default behavior (no args) compares the two most recent tags.
Example Output
Running this against a real repo produces something like:
# Changelog
## [v1.3.0] β 2026-06-28
### π Features
- Added dark mode toggle with system preference detection
- Implemented webhook retry logic with exponential backoff
- Added CSV export for dashboard analytics
### π Bug Fixes
- Fixed race condition in WebSocket reconnection handler
- Resolved pagination offset error on filtered queries
### π Documentation
- Updated API reference with new webhook endpoints
- Added deployment guide for Railway
The AI merges related commits, rewrites terse messages into readable descriptions, and keeps everything consistent. Compare that to a raw git log dump.
How the Grouping Works
The script relies on conventional commit prefixes. A commit like feat(auth): add OAuth2 PKCE flow gets parsed into type feat with the message extracted. Commits without a recognized prefix land in the βOtherβ bucket.
If your team doesnβt use conventional commits yet, the script still works β everything just goes into βOtherβ and the AI summarizes it as one block. But the output is much better with typed commits. Our AI commit message generator can enforce this format automatically.
Choosing a Model
For changelog generation, you want a model thatβs good at summarization and can handle longer inputs (lots of commit messages at once):
| Model | Speed | Best For |
|---|---|---|
qwen2.5-coder:7b | ~3-5s/group | Default, good balance |
llama3.1:8b | ~3-4s/group | Slightly more natural language |
qwen2.5-coder:3b | ~1-2s/group | Fast, good enough for small releases |
The script skips the AI call entirely for groups with 2 or fewer commits β no point summarizing whatβs already concise.
Integrating Into Your Release Flow
Add it to a Makefile or npm script so it runs automatically when you tag:
changelog:
python3 changelog_gen.py --last 1 >> CHANGELOG.md
git add CHANGELOG.md
git commit -m "docs: update changelog"
release:
@read -p "Version: " version; \
git tag -a $$version -m "Release $$version"; \
$(MAKE) changelog
Or hook it into a GitHub Actions workflow that generates the changelog on tag push and attaches it to the release.
Tips
- Large repos: If you have hundreds of commits between tags, the grouping keeps context windows manageable β each type is summarized separately.
- Non-conventional commits: The βOtherβ bucket catches everything. The AI still does a decent job summarizing mixed commit messages.
- Markdown formatting: The output is valid markdown. Pipe it into your docs site, paste it into GitHub Releases, or append to an existing
CHANGELOG.md. Check our markdown cheat sheet if you want to customize the format. - Git stash conflicts: If youβre mid-work when you need to generate a changelog, stash your changes first. Our git stash cheat sheet covers the workflow.
- Temperature: Kept at 0.3 for consistent output. Raise to 0.5 if you want more varied phrasing across runs.
What You Built
A single Python script that turns your git tag history into a polished, categorized changelog β powered by a local LLM. It parses conventional commits, groups them by type, asks Ollama to summarize each group into human-readable bullet points, and outputs clean markdown. No API keys, no cloud services, no dependencies beyond Python and Ollama.
Run it once per release and never hand-write a changelog again.