How to Implement a Release Notes Generator from Git Commits in Batch Script
Release notes communicate what changed in a new version: features added, bugs fixed, improvements made, and breaking changes introduced. Generating release notes automatically from Git commit messages eliminates the tedious manual process, ensures no changes are missed, and creates consistent documentation for every release. A Batch Script can extract commit history between tags, categorize changes, and format them into readable release notes.
In this guide, we will explore how to build a release notes generator from Git commits using a Batch Script.
Understanding Conventional Commits
The generator works best with Conventional Commits, a structured commit message format:
type(scope): description
feat: add user registration endpoint
fix: resolve login timeout issue
docs: update API documentation
chore: upgrade dependency versions
breaking: remove deprecated /v1 endpoint
| Prefix | Category |
|---|---|
feat: | New Features |
fix: | Bug Fixes |
docs: | Documentation |
refactor: | Code Refactoring |
test: | Tests |
chore: | Maintenance |
perf: | Performance |
breaking: | Breaking Changes |
Method 1: Basic Release Notes Between Tags
@echo off
setlocal enabledelayedexpansion
:: Verify git is available
where git >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Git not found.
pause
exit /b 1
)
:: Get the two most recent tags
set "latest="
set "previous="
set "count=0"
for /f "delims=" %%T in ('git tag --sort=-version:refname -l "v*" 2^>nul') do (
set /a count+=1
if !count!==1 set "latest=%%T"
if !count!==2 set "previous=%%T"
)
if not defined latest (
echo [ERROR] No version tags found (expected tags matching "v*"^).
pause
exit /b 1
)
if not defined previous (
set "range=!latest!"
echo Generating notes for all commits up to !latest!...
) else (
set "range=!previous!..!latest!"
echo Generating notes: !previous! -^> !latest!
)
echo.
echo # Release Notes - !latest!
echo.
echo ## Changes
echo.
for /f "delims=" %%L in ('git log !range! --pretty^=format:"- %%s (%%h)" --no-merges 2^>nul') do (
echo %%L
)
echo.
pause
Method 2: Categorized Release Notes
Sort commits into categories based on prefixes:
@echo off
setlocal enabledelayedexpansion
:: Configuration
set "output=RELEASE_NOTES.md"
set "tag_from=%~1"
set "tag_to=%~2"
:: Verify git
where git >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Git not found.
exit /b 1
)
:: Auto-detect tags if not provided
if not defined tag_to (
set "count=0"
for /f "delims=" %%T in ('git tag --sort=-version:refname -l "v*" 2^>nul') do (
set /a count+=1
if !count!==1 set "tag_to=%%T"
if !count!==2 set "tag_from=%%T"
)
)
if not defined tag_to (
echo [ERROR] No version tags found.
exit /b 1
)
if defined tag_from (
set "range=!tag_from!..!tag_to!"
) else (
set "range=!tag_to!"
)
echo Generating release notes: !range!
echo.
:: Generate timestamp
for /f "tokens=2 delims==" %%T in ('wmic os get LocalDateTime /value') do set "dt=%%T"
set "release_date=%dt:~0,4%-%dt:~4,2%-%dt:~6,2%"
:: Create release notes file
(
echo # Release Notes - !tag_to!
echo.
echo **Date:** !release_date!
echo.
) > "%output%"
:: Collect all commits once into a temp file to avoid repeated git calls
set "commits_file=%temp%\commits_%random%.txt"
git log !range! --pretty=format:"%%s|%%h|%%an" --no-merges > "!commits_file!" 2>nul
:: Write categorized sections
call :write_section "New Features" "feat" "!commits_file!"
call :write_section "Bug Fixes" "fix" "!commits_file!"
call :write_section "Documentation" "docs" "!commits_file!"
call :write_section "Performance" "perf" "!commits_file!"
call :write_section "Breaking Changes" "breaking BREAKING" "!commits_file!"
:: Statistics
(
echo ## Statistics
echo.
) >> "%output%"
for /f %%C in ('find /c /v "" ^< "!commits_file!"') do (
echo - **Commits:** %%C>> "%output%"
)
:: Count unique authors
set "authors_file=%temp%\authors_%random%.txt"
for /f "tokens=3 delims=|" %%A in ('type "!commits_file!"') do echo %%A>> "!authors_file!"
if exist "!authors_file!" (
for /f %%C in ('sort "!authors_file!" /unique ^| find /c /v ""') do (
echo - **Contributors:** %%C>> "%output%"
)
del "!authors_file!" 2>nul
)
del "!commits_file!" 2>nul
echo.
echo [SUCCESS] Release notes generated: %output%
echo.
type "%output%"
pause
exit /b 0
:write_section
:: %~1 = section title, %~2 = search pattern(s), %~3 = commits file
set "found=0"
for /f "delims=" %%L in ('findstr /i /b "%~2" "%~3" 2^>nul') do set "found=1"
if !found!==1 (
echo ## %~1>> "%output%"
echo.>> "%output%"
for /f "tokens=1,2,3 delims=|" %%A in ('findstr /i /b "%~2" "%~3"') do (
echo - %%A ^(%%B^)>> "%output%"
)
echo.>> "%output%"
)
exit /b 0
Method 3: PowerShell-Enhanced Generator
For more sophisticated categorization and formatting:
@echo off
setlocal
set "output=RELEASE_NOTES.md"
:: Verify git
where git >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Git not found.
exit /b 1
)
echo Generating comprehensive release notes...
powershell -NoProfile -Command ^
"$tags = @(git tag --sort=-version:refname -l 'v*' 2>$null);" ^
"if ($tags.Count -eq 0) { Write-Host '[ERROR] No version tags found.'; exit 1 };" ^
"$latest = $tags[0];" ^
"$previous = if ($tags.Count -gt 1) { $tags[1] } else { $null };" ^
"$range = if ($previous) { '{0}..{1}' -f $previous, $latest } else { $latest };" ^
"$commits = @(git log $range --pretty=format:'%%H|%%h|%%s|%%an|%%ae|%%ai' --no-merges 2>$null);" ^
"if ($commits.Count -eq 0) { Write-Host '[WARNING] No commits found in range.'; exit 0 };" ^
"$notes = [System.Collections.Generic.List[string]]::new();" ^
"$notes.Add('# Release Notes - ' + $latest);" ^
"$notes.Add('');" ^
"$notes.Add('**Released:** ' + (Get-Date -Format 'MMMM dd, yyyy'));" ^
"if ($previous) { $notes.Add('**Changes since:** ' + $previous) };" ^
"$notes.Add('');" ^
"$categories = [ordered]@{" ^
" 'Features' = [System.Collections.Generic.List[string]]::new();" ^
" 'Bug Fixes' = [System.Collections.Generic.List[string]]::new();" ^
" 'Performance' = [System.Collections.Generic.List[string]]::new();" ^
" 'Documentation' = [System.Collections.Generic.List[string]]::new();" ^
" 'Refactoring' = [System.Collections.Generic.List[string]]::new();" ^
" 'Other' = [System.Collections.Generic.List[string]]::new()" ^
"};" ^
"foreach ($c in $commits) {" ^
" $parts = $c -split '\|', 6;" ^
" if ($parts.Count -lt 3) { continue };" ^
" $msg = $parts[2]; $hash = $parts[1];" ^
" $entry = '- ' + $msg + ' (``' + $hash + '``)';" ^
" if ($msg -match '^feat') { $categories['Features'].Add($entry) }" ^
" elseif ($msg -match '^fix') { $categories['Bug Fixes'].Add($entry) }" ^
" elseif ($msg -match '^perf') { $categories['Performance'].Add($entry) }" ^
" elseif ($msg -match '^docs') { $categories['Documentation'].Add($entry) }" ^
" elseif ($msg -match '^refactor') { $categories['Refactoring'].Add($entry) }" ^
" else { $categories['Other'].Add($entry) }" ^
"};" ^
"foreach ($cat in $categories.Keys) {" ^
" if ($categories[$cat].Count -gt 0) {" ^
" $notes.Add('## ' + $cat); $notes.Add('');" ^
" $categories[$cat] | ForEach-Object { $notes.Add($_) };" ^
" $notes.Add('')" ^
" }" ^
"};" ^
"$notes.Add('## Statistics'); $notes.Add('');" ^
"$notes.Add('- **Total commits:** ' + $commits.Count);" ^
"$authors = $commits | ForEach-Object { ($_ -split '\|', 6)[3] } | Sort-Object -Unique;" ^
"$notes.Add('- **Contributors:** ' + @($authors).Count);" ^
"$notes | Out-File '%output%' -Encoding UTF8;" ^
"Write-Host ('Generated: %output% (' + $commits.Count + ' commits)');" ^
"exit 0"
if %errorlevel% neq 0 (
echo [ERROR] Generation failed.
exit /b 1
)
echo.
type "%output%"
pause
Method 4: Changelog Appender
Append each release's notes to a running CHANGELOG.md:
@echo off
setlocal enabledelayedexpansion
set "changelog=CHANGELOG.md"
:: Verify git
where git >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Git not found.
exit /b 1
)
:: Get latest and previous tags
set "tag="
set "prev_tag="
set "count=0"
for /f "delims=" %%T in ('git tag --sort=-version:refname -l "v*" 2^>nul') do (
set /a count+=1
if !count!==1 set "tag=%%T"
if !count!==2 set "prev_tag=%%T"
)
if not defined tag (
echo [ERROR] No version tags found.
exit /b 1
)
if defined prev_tag (
set "range=!prev_tag!..!tag!"
) else (
set "range=!tag!"
)
:: Generate timestamp
for /f "tokens=2 delims==" %%T in ('wmic os get LocalDateTime /value') do set "dt=%%T"
set "release_date=%dt:~0,4%-%dt:~4,2%-%dt:~6,2%"
echo Generating notes for !tag!...
:: Generate this release's notes into a temp file
set "temp_notes=%temp%\release_notes_%random%.txt"
(
echo ## !tag! - !release_date!
echo.
) > "!temp_notes!"
set "commit_count=0"
for /f "delims=" %%L in ('git log !range! --pretty^=format:"- %%s (%%h)" --no-merges 2^>nul') do (
echo %%L>> "!temp_notes!"
set /a commit_count+=1
)
if !commit_count!==0 (
echo (no changes^)>> "!temp_notes!"
)
echo.>> "!temp_notes!"
:: Prepend to changelog
if exist "%changelog%" (
:: Save existing content
set "existing=%temp%\changelog_existing_%random%.txt"
copy "%changelog%" "!existing!" >nul
:: Write: header + new notes + existing body (skip old header if present)
set "header_written=0"
(
echo # Changelog
echo.
type "!temp_notes!"
) > "%changelog%"
:: Append existing content, skipping the first "# Changelog" header line
set "skip_header=1"
for /f "usebackq delims=" %%L in ("!existing!") do (
if !skip_header!==1 (
echo %%L | findstr /b "# Changelog" >nul
if !errorlevel!==0 (
set "skip_header=0"
) else (
set "skip_header=0"
echo %%L>> "%changelog%"
)
) else (
echo %%L>> "%changelog%"
)
)
del "!existing!" 2>nul
) else (
(
echo # Changelog
echo.
type "!temp_notes!"
) > "%changelog%"
)
del "!temp_notes!" 2>nul
echo [OK] %changelog% updated with !tag! notes (!commit_count! commits^).
pause
Common Mistakes
The Wrong Way: Including Merge Commits
:: WRONG - Merge commits clutter release notes
git log v1.0.0..v2.0.0 --pretty=format:"- %%s"
:: Output includes: "Merge branch 'feature/xyz' into main"
Output Concern:
Merge commits are administrative and do not represent actual changes. Always use --no-merges to exclude them from release notes.
The Wrong Way: Unstructured Commit Messages
:: PROBLEMATIC - Random commit messages cannot be categorized
:: "fixed stuff", "updates", "wip", "asdf"
Release notes are only as good as the commit messages. Adopt Conventional Commits (feat:, fix:, docs:) to enable automatic categorization.
Best Practices
- Use Conventional Commits: Structured messages enable automatic categorization.
- Exclude merge commits: Use
--no-mergesfor cleaner release notes. - Include commit hashes: Short hashes provide traceability back to the source.
- Maintain a CHANGELOG.md: Append release notes to a running changelog for each release.
- Generate notes as part of the release process: Automate note generation in your release workflow.
Conclusion
Implementing a release notes generator from Git commits in Batch Script automates documentation that is often neglected or inconsistently maintained. By extracting commits between version tags, categorizing them by conventional commit prefixes, and formatting them into structured Markdown, teams produce professional release notes with every version. The generator is most effective when combined with Conventional Commits conventions, ensuring that every commit message contributes to clear, categorized release documentation.