我来提供一个功能完善的变更日志生成脚本,这个脚本可以分析Git提交历史并生成结构化的CHANGELOG.md文件。

完整的变更日志生成脚本
Bash版本(推荐,适用于大多数项目)
#!/bin/bash
# 变更日志生成器
# 用法: ./changelog.sh [tag1] [tag2] 或 ./changelog.sh
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 检查是否在Git仓库中
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo -e "${RED}错误: 不在Git仓库中${NC}"
exit 1
fi
# 获取版本信息
get_version() {
local tag=$1
if [ -z "$tag" ]; then
echo "未发布版本"
else
echo "$tag"
fi
}
# 分类提交信息
classify_commit() {
local msg="$1"
# 转换为小写
local lower_msg=$(echo "$msg" | tr '[:upper:]' '[:lower:]')
# 根据提交信息分类
if echo "$lower_msg" | grep -qE '^(feat|feature|add|new|implement)'; then
echo "features"
elif echo "$lower_msg" | grep -qE '^(fix|bug|bugfix|hotfix|correct|resolve)'; then
echo "fixes"
elif echo "$lower_msg" | grep -qE '^(docs?|document)'; then
echo "documentation"
elif echo "$lower_msg" | grep -qE '^(refactor|refactoring|improve|enhance|optimize)'; then
echo "improvements"
elif echo "$lower_msg" | grep -qE '^(test|testing)'; then
echo "tests"
elif echo "$lower_msg" | grep -qE '^(chore|build|ci|config|deps?)'; then
echo "chores"
elif echo "$lower_msg" | grep -qE '^(style|format|lint)'; then
echo "style"
elif echo "$lower_msg" | grep -qE '^(perf|performance)'; then
echo "performance"
else
echo "other"
fi
}
# 格式化提交信息
format_commit() {
local msg="$1"
local author="$2"
local hash="$3"
# 移除常用的前缀
local formatted=$(echo "$msg" | sed -E 's/^(feat|fix|docs|refactor|test|chore|style|perf)(\([^)]+\))?:\s*//i')
# 添加链接格式
echo "- ${formatted} ([${hash:0:7}](https://github.com/your-repo/commit/${hash}))"
}
# 生成单个版本的变更日志
generate_version_changelog() {
local from_tag="$1"
local to_tag="$2"
local output_file="$3"
# 确定日期范围
local date_range=""
if [ -n "$to_tag" ]; then
local to_date=$(git log -1 --format="%ai" "$to_tag" | cut -d' ' -f1)
date_range=" - $to_date"
else
local to_date=$(date +%Y-%m-%d)
date_range=""
fi
# 获取版本号
local version=$(get_version "$to_tag")
# 获取提交日志
if [ -n "$from_tag" ] && [ -n "$to_tag" ]; then
local commits=$(git log "$from_tag..$to_tag" --oneline --no-merges --format="%h||%s||%an||%ai" 2>/dev/null)
elif [ -n "$to_tag" ]; then
local commits=$(git log "$to_tag" --oneline --no-merges --format="%h||%s||%an||%ai" 2>/dev/null)
else
local commits=$(git log --oneline --no-merges --format="%h||%s||%an||%ai" 2>/dev/null)
fi
if [ -z "$commits" ]; then
echo "没有找到提交记录"
return
fi
# 分类提交
declare -A categories
categories["features"]="🚀 新功能"
categories["fixes"]="🐛 修复"
categories["documentation"]="📝 文档"
categories["improvements"]="🔧 改进与优化"
categories["tests"]="🧪 测试"
categories["chores"]="🔨 杂项"
categories["style"]="🎨 样式"
categories["performance"]="⚡ 性能"
categories["other"]="其他"
# 初始化分类数组
for key in "${!categories[@]}"; do
eval "${key}_commits=()"
done
# 分类处理每个提交
while IFS='||' read -r hash msg author date; do
if [ -n "$hash" ]; then
local category=$(classify_commit "$msg")
local formatted_msg=$(format_commit "$msg" "$author" "$hash")
eval "${category}_commits+=(\"$formatted_msg\")"
fi
done <<< "$commits"
# 写入变更日志
{
echo ""
echo "## [$version]$date_range"
echo ""
# 按类别输出
for key in features fixes documentation improvements tests chores style performance other; do
eval "local count=\${#${key}_commits[@]}"
if [ "$count" -gt 0 ]; then
echo "### ${categories[$key]}"
echo ""
eval "for commit in \"\${${key}_commits[@]}\"; do echo \"\$commit\"; done"
echo ""
fi
done
} >> "$output_file"
}
# 主函数
main() {
local output_file="${1:-CHANGELOG.md}"
local start_tag="$2"
local end_tag="$3"
echo -e "${BLUE}生成变更日志...${NC}"
# 创建或清空输出文件
> "$output_file"
# 写入文件头
{
echo "# 变更日志"
echo ""
echo "项目变更记录"
echo ""
echo "---"
echo ""
} > "$output_file"
if [ -n "$start_tag" ] && [ -n "$end_tag" ]; then
# 生成两个标签之间的变更日志
echo -e "${GREEN}生成标签 $start_tag 到 $end_tag 的变更日志${NC}"
generate_version_changelog "$start_tag" "$end_tag" "$output_file"
elif [ -n "$end_tag" ]; then
# 生成从开始到指定标签的变更日志
echo -e "${GREEN}生成标签 $end_tag 的变更日志${NC}"
generate_version_changelog "" "$end_tag" "$output_file"
else
# 生成所有标签的变更日志
echo -e "${GREEN}生成Git仓库的完整变更日志${NC}"
# 获取所有标签(按版本排序)
local tags=$(git tag --sort=-version:refname)
local prev_tag=""
local first_tag=""
if [ -n "$tags" ]; then
# 处理标签间的变更
for tag in $tags; do
if [ -z "$prev_tag" ]; then
first_tag="$tag"
else
echo -e "${YELLOW}处理标签 $tag 到 $prev_tag${NC}"
generate_version_changelog "$tag" "$prev_tag" "$output_file"
fi
prev_tag="$tag"
done
# 处理第一个标签之前的变更
if [ -n "$first_tag" ]; then
echo -e "${YELLOW}处理初始版本到 $first_tag${NC}"
generate_version_changelog "" "$first_tag" "$output_file"
fi
else
# 没有标签,生成全部变更
echo -e "${YELLOW}未找到标签,生成全部变更日志${NC}"
generate_version_changelog "" "" "$output_file"
fi
fi
echo -e "${GREEN}✓ 变更日志已生成到 $output_file${NC}"
}
# 显示帮助
show_help() {
echo "用法: $0 [选项] [输出文件] [起始标签] [结束标签]"
echo ""
echo "选项:"
echo " -h, --help 显示帮助信息"
echo ""
echo "示例:"
echo " $0 生成完整的变更日志"
echo " $0 CHANGELOG.md 指定输出文件"
echo " $0 CHANGELOG.md v1.0.0 v2.0.0 生成两个版本之间的变更"
echo " $0 CHANGELOG.md '' v1.0.0 生成到指定版本的变更"
}
# 参数解析
case "$1" in
-h|--help)
show_help
exit 0
;;
*)
main "$@"
;;
esac
Python版本(更强大的功能)
#!/usr/bin/env python3
"""
变更日志生成器 - Python版本
自动从Git提交历史生成结构化CHANGELOG.md
"""
import re
import subprocess
import sys
from datetime import datetime
from collections import defaultdict
import os
class ChangelogGenerator:
def __init__(self, repo_path="."):
self.repo_path = repo_path
self.categories = {
'features': '🚀 新功能',
'fixes': '🐛 修复',
'documentation': '📝 文档',
'improvements': '🔧 改进与优化',
'tests': '🧪 测试',
'chores': '🔨 杂项',
'style': '🎨 样式',
'performance': '⚡ 性能',
'breaking': '💥 破坏性变更',
'other': '其他'
}
def run_git_command(self, command):
"""运行Git命令并返回输出"""
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
cwd=self.repo_path
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Git命令失败: {e}")
return ""
def get_tags(self):
"""获取所有标签"""
tags = self.run_git_command("git tag --sort=-version:refname")
return tags.split('\n') if tags else []
def classify_commit(self, message):
"""分类提交信息"""
# 检出破坏性变更
if re.search(r'breaking|BREAKING|!:', message):
return 'breaking'
# 使用Conventional Commits标准
type_map = {
'feat': 'features',
'feature': 'features',
'fix': 'fixes',
'bugfix': 'fixes',
'docs': 'documentation',
'doc': 'documentation',
'refactor': 'improvements',
'improve': 'improvements',
'enhance': 'improvements',
'optimize': 'improvements',
'test': 'tests',
'testing': 'tests',
'chore': 'chores',
'build': 'chores',
'ci': 'chores',
'style': 'style',
'perf': 'performance',
'performance': 'performance',
'add': 'features',
'new': 'features',
'implement': 'features',
'remove': 'breaking',
'delete': 'breaking',
}
commit_type = re.match(r'^(\w+)', message.lower())
if commit_type:
return type_map.get(commit_type.group(1), 'other')
return 'other'
def format_commit(self, hash, message, author):
"""格式化提交信息"""
# 移除前缀
formatted = re.sub(
r'^(feat|fix|docs|refactor|test|chore|style|perf)(\([^)]+\))?:\s*',
'',
message,
flags=re.IGNORECASE
)
# 移除尾随的空格
formatted = formatted.strip()
# 生成链接(请替换为实际的仓库URL)
repo_url = self.get_repo_url()
commit_url = f"{repo_url}/commit/{hash}" if repo_url else f"#{hash}"
return f"- {formatted} ([{hash[:7]}]({commit_url}))"
def get_repo_url(self):
"""获取仓库URL"""
remote = self.run_git_command("git remote get-url origin")
if remote:
# 转换SSH格式到HTTPS
remote = re.sub(r'git@([^:]+):', r'https://\1/', remote)
remote = re.sub(r'\.git$', '', remote)
return remote
return ""
def get_commits_between(self, from_tag=None, to_tag=None):
"""获取两个标签之间的提交"""
if from_tag and to_tag:
cmd = f"git log {from_tag}..{to_tag} --oneline --no-merges --format='%h||%s||%an||%ai'"
elif to_tag:
cmd = f"git log {to_tag} --oneline --no-merges --format='%h||%s||%an||%ai'"
else:
cmd = "git log --oneline --no-merges --format='%h||%s||%an||%ai'"
result = self.run_git_command(cmd)
commits = []
for line in result.split('\n'):
if line:
parts = line.split('||')
if len(parts) >= 4:
commits.append({
'hash': parts[0],
'message': parts[1],
'author': parts[2],
'date': parts[3]
})
return commits
def generate_version_section(self, version, commits, date=None):
"""生成单个版本的变更日志部分"""
section = []
# 版本标题
if date:
section.append(f"\n## [{version}] - {date}\n")
else:
section.append(f"\n## [{version}]\n")
# 分类提交
classified = defaultdict(list)
for commit in commits:
category = self.classify_commit(commit['message'])
formatted = self.format_commit(
commit['hash'],
commit['message'],
commit['author']
)
classified[category].append(formatted)
# 按类别输出
for category, label in self.categories.items():
if category in classified and classified[category]:
section.append(f"\n### {label}\n")
for commit in classified[category]:
section.append(commit)
section.append("")
return '\n'.join(section)
def generate(self, output_file="CHANGELOG.md"):
"""生成完整的变更日志"""
# 获取所有标签
tags = self.get_tags()
# 初始化变更日志内容
changelog = [
"# 变更日志\n",
"本项目所有值得注意的变更都将记录在此文件中。\n",
"格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),",
"版本遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。\n",
"---\n"
]
# 处理标签之间的提交
prev_tag = None
all_commits_processed = set()
if tags:
# 最新版本
latest_commits = self.get_commits_between(to_tag=tags[0])
if latest_commits:
date = latest_commits[0]['date'][:10]
version_section = self.generate_version_section(
tags[0], latest_commits, date
)
changelog.append(version_section)
all_commits_processed.update(
c['hash'] for c in latest_commits
)
# 中间版本
for i in range(len(tags) - 1):
from_tag = tags[i + 1]
to_tag = tags[i]
version_commits = self.get_commits_between(from_tag, to_tag)
# 过滤已处理的提交
new_commits = [
c for c in version_commits
if c['hash'] not in all_commits_processed
]
if new_commits:
date = new_commits[0]['date'][:10]
version_section = self.generate_version_section(
to_tag, new_commits, date
)
changelog.append(version_section)
all_commits_processed.update(
c['hash'] for c in new_commits
)
# 初始版本
initial_commits = self.get_commits_between(to_tag=tags[-1])
# 过滤已处理的
new_initial = [
c for c in initial_commits
if c['hash'] not in all_commits_processed
]
if new_initial:
version_section = self.generate_version_section(
"初始版本", new_initial
)
changelog.append(version_section)
else:
# 没有标签,处理所有提交
all_commits = self.get_commits_between()
if all_commits:
version_section = self.generate_version_section(
"Unreleased", all_commits
)
changelog.append(version_section)
# 写入文件
with open(output_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(changelog))
print(f"✓ 变更日志已生成到 {output_file}")
return output_file
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(
description='生成Git仓库的变更日志'
)
parser.add_argument(
'-o', '--output',
default='CHANGELOG.md',
help='输出文件路径 (默认: CHANGELOG.md)'
)
parser.add_argument(
'-p', '--path',
default='.',
help='Git仓库路径 (默认: 当前目录)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='显示详细输出'
)
args = parser.parse_args()
generator = ChangelogGenerator(args.path)
if args.verbose:
print(f"生成变更日志...")
print(f"仓库路径: {args.path}")
print(f"输出文件: {args.output}")
generator.generate(args.output)
if __name__ == "__main__":
main()
使用说明
基本使用
# 设置执行权限 chmod +x changelog.sh # 生成完整的变更日志 ./changelog.sh # 指定输出文件名 ./changelog.sh HISTORY.md # 生成两个版本之间的变更 ./changelog.sh CHANGELOG.md v1.0.0 v2.0.0 # Python版本 python3 changelog.py python3 changelog.py -o CHANGELOG.md -v
提交信息规范建议
为了获得最佳效果,建议使用 Conventional Commits 规范:
feat: 新功能 fix: 修复bug docs: 文档变更 style: 样式调整 refactor: 代码重构 test: 测试相关 chore: 构建/配置变更 perf: 性能优化 BREAKING CHANGE: 破坏性变更
Git钩子集成
可以将其添加到 post-commit 钩子中自动更新:
#!/bin/bash # .git/hooks/post-commit # 自动更新变更日志 /path/to/changelog.sh
这个脚本会自动分析Git历史,按版本和类型分类提交,生成结构清晰、美观的变更日志文档。