Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:47 +08:00
commit aca019f43d
14 changed files with 1826 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
CHANGELOG generator from git commit history.
Supports Conventional Commits format.
"""
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
class ChangelogGenerator:
def __init__(self, repo_path: str = "."):
self.repo_path = Path(repo_path).resolve()
# Conventional Commits types
self.commit_types = {
'feat': 'Added',
'fix': 'Fixed',
'docs': 'Documentation',
'style': 'Style',
'refactor': 'Changed',
'perf': 'Performance',
'test': 'Tests',
'chore': 'Chore',
'build': 'Build',
'ci': 'CI/CD',
}
def get_commits_since_tag(
self,
since_tag: Optional[str] = None) -> List[str]:
"""Get commit messages since last tag."""
try:
if since_tag:
cmd = [
'git',
'log',
f'{since_tag}..HEAD',
'--pretty=format:%s']
else:
# Get all commits if no tag specified
cmd = ['git', 'log', '--pretty=format:%s']
result = subprocess.run(
cmd,
cwd=self.repo_path,
capture_output=True,
text=True,
check=True,
encoding='utf-8'
)
return (
result.stdout.strip().split('\n')
if result.stdout.strip() else []
)
except subprocess.CalledProcessError as e:
print(f"Error getting git commits: {e.stderr}", file=sys.stderr)
return []
def parse_commit(self, commit_message: str) -> Dict[str, str]:
"""Parse conventional commit message."""
# Pattern: type(scope): description
pattern = r'^(\w+)(?:\(([^)]+)\))?: (.+)$'
match = re.match(pattern, commit_message)
if match:
commit_type, scope, description = match.groups()
return {
'type': commit_type,
'scope': scope or '',
'description': description,
'raw': commit_message
}
else:
return {
'type': 'other',
'scope': '',
'description': commit_message,
'raw': commit_message
}
def group_commits(self, commits: List[str]) -> Dict[str, List[str]]:
"""Group commits by type."""
grouped: Dict[str, List[str]] = {}
for commit_msg in commits:
parsed = self.parse_commit(commit_msg)
commit_type = parsed['type']
description = parsed['description']
category = self.commit_types.get(commit_type, 'Other')
if category not in grouped:
grouped[category] = []
grouped[category].append(description)
return grouped
def generate_changelog_entry(
self,
version: str,
since_tag: Optional[str] = None) -> str:
"""Generate changelog entry for new version."""
commits = self.get_commits_since_tag(since_tag)
if not commits:
print("No commits found", file=sys.stderr)
return ""
grouped = self.group_commits(commits)
# Build changelog entry
date = datetime.now().strftime('%Y-%m-%d')
lines = [
f"## [{version}] - {date}",
""
]
# Add sections in order
section_order = [
'Added', 'Fixed', 'Changed', 'Deprecated', 'Removed',
'Security', 'Performance', 'Documentation', 'Other'
]
for section in section_order:
if section in grouped:
lines.append(f"### {section}")
for item in grouped[section]:
lines.append(f"- {item}")
lines.append("")
return '\n'.join(lines)
def update_changelog_file(
self,
version: str,
since_tag: Optional[str] = None,
changelog_path: str = "CHANGELOG.md"
) -> bool:
"""Update CHANGELOG.md file with new entry."""
entry = self.generate_changelog_entry(version, since_tag)
if not entry:
return False
changelog_file = self.repo_path / changelog_path
if changelog_file.exists():
# Insert at top after header
content = changelog_file.read_text(encoding='utf-8')
# Find position to insert (after # Changelog header)
lines = content.split('\n')
insert_pos = 0
for i, line in enumerate(lines):
if line.startswith('# '):
insert_pos = i + 1
# Skip empty lines after header
while (insert_pos < len(lines) and
not lines[insert_pos].strip()):
insert_pos += 1
break
lines.insert(insert_pos, entry)
new_content = '\n'.join(lines)
else:
# Create new CHANGELOG
new_content = f"# Changelog\n\n{entry}"
changelog_file.write_text(new_content, encoding='utf-8')
print(f"✓ Updated {changelog_path}")
return True
def main():
if len(sys.argv) < 2:
print(
"Usage: python changelog_generator.py "
"<version> [since_tag] [changelog_path]",
file=sys.stderr
)
sys.exit(1)
version = sys.argv[1]
since_tag = sys.argv[2] if len(sys.argv) > 2 else None
changelog_path = sys.argv[3] if len(sys.argv) > 3 else "CHANGELOG.md"
generator = ChangelogGenerator()
if not generator.update_changelog_file(version, since_tag, changelog_path):
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Detect project type and version files automatically.
Supports: Node.js, Python, Rust, Go, Unity, Unreal Engine,
Claude Code Plugins, etc.
"""
import json
import sys
import tomllib # Python 3.11+ required
from pathlib import Path
from typing import Any, Dict, Optional
# Check Python version
if sys.version_info < (3, 11):
print(
"Error: Python 3.11+ is required for this script",
file=sys.stderr
)
print(
"Current version: "
f"{sys.version_info.major}.{sys.version_info.minor}",
file=sys.stderr
)
sys.exit(1)
class ProjectDetector:
def __init__(self, project_root: str = "."):
self.root = Path(project_root).resolve()
def detect(self) -> Dict[str, Any]:
"""Detect project type and return version file paths."""
result: Dict[str, Any] = {
"project_type": None,
"version_files": [],
"detected_version": None
}
# Check in priority order
detectors = [
self._detect_unity,
self._detect_unreal,
self._detect_nodejs,
self._detect_python,
self._detect_rust,
self._detect_go,
self._detect_claude_plugin,
self._detect_generic
]
for detector in detectors:
detection = detector()
if detection:
result.update(detection)
break
return result
def _detect_unity(self) -> Optional[Dict[str, Any]]:
"""Detect Unity project."""
project_settings = (
self.root / "ProjectSettings" / "ProjectSettings.asset"
)
version_json = self.root / "version.json"
if project_settings.exists():
files = [str(project_settings)]
if version_json.exists():
files.append(str(version_json))
# Try to read version from version.json first
version = None
if version_json.exists():
try:
data = json.loads(
version_json.read_text(encoding='utf-8')
)
version = data.get('version')
except Exception:
pass
# Fallback to ProjectSettings.asset
if not version:
try:
content = project_settings.read_text(
encoding='utf-8'
)
for line in content.split('\n'):
if 'bundleVersion:' in line:
version = line.split(':', 1)[1].strip()
break
except Exception:
pass
return {
"project_type": "unity",
"version_files": files,
"detected_version": version
}
return None
def _detect_unreal(self) -> Optional[Dict[str, Any]]:
"""Detect Unreal Engine project."""
uproject_files = list(self.root.glob("*.uproject"))
if uproject_files:
uproject = uproject_files[0]
version = None
try:
data = json.loads(uproject.read_text(encoding='utf-8'))
version = data.get('Version') or data.get('EngineAssociation')
except (json.JSONDecodeError, IOError, UnicodeDecodeError):
pass
return {
"project_type": "unreal",
"version_files": [str(uproject)],
"detected_version": version
}
return None
def _detect_nodejs(self) -> Optional[Dict[str, Any]]:
"""Detect Node.js project."""
package_json = self.root / "package.json"
if package_json.exists():
version = None
try:
data = json.loads(package_json.read_text(encoding='utf-8'))
version = data.get('version')
except BaseException:
pass
return {
"project_type": "nodejs",
"version_files": [str(package_json)],
"detected_version": version
}
return None
def _detect_python(self) -> Optional[Dict[str, Any]]:
"""Detect Python project."""
pyproject = self.root / "pyproject.toml"
setup_py = self.root / "setup.py"
if pyproject.exists():
version = None
if tomllib:
try:
data = tomllib.loads(
pyproject.read_text(encoding='utf-8')
)
version = (
data.get('project', {}).get('version') or
data.get('tool', {}).get('poetry', {}).get('version')
)
except Exception:
pass
return {
"project_type": "python",
"version_files": [str(pyproject)],
"detected_version": version
}
elif setup_py.exists():
return {
"project_type": "python",
"version_files": [str(setup_py)],
"detected_version": None # Requires parsing Python
}
return None
def _detect_rust(self) -> Optional[Dict[str, Any]]:
"""Detect Rust project."""
cargo_toml = self.root / "Cargo.toml"
if cargo_toml.exists():
version = None
if tomllib:
try:
data = tomllib.loads(
cargo_toml.read_text(
encoding='utf-8'))
version = data.get('package', {}).get('version')
except BaseException:
pass
return {
"project_type": "rust",
"version_files": [str(cargo_toml)],
"detected_version": version
}
return None
def _detect_go(self) -> Optional[Dict[str, Any]]:
"""Detect Go project."""
go_mod = self.root / "go.mod"
if go_mod.exists():
# Go doesn't have built-in versioning in go.mod
# Check for common version files
version_file = self.root / "VERSION"
if version_file.exists():
version = version_file.read_text().strip()
return {
"project_type": "go",
"version_files": [str(version_file)],
"detected_version": version
}
return {
"project_type": "go",
"version_files": [],
"detected_version": None
}
return None
def _detect_claude_plugin(self) -> Optional[Dict[str, Any]]:
"""Detect Claude Code plugin."""
plugin_json = self.root / ".claude-plugin" / "plugin.json"
if plugin_json.exists():
version = None
try:
data = json.loads(plugin_json.read_text(encoding='utf-8'))
version = data.get('version')
except BaseException:
pass
return {
"project_type": "claude-plugin",
"version_files": [str(plugin_json)],
"detected_version": version
}
return None
def _detect_generic(self) -> Optional[Dict[str, Any]]:
"""Detect generic project with VERSION file."""
version_file = self.root / "VERSION"
if version_file.exists():
version = version_file.read_text().strip()
return {
"project_type": "generic",
"version_files": [str(version_file)],
"detected_version": version
}
return {
"project_type": "unknown",
"version_files": [],
"detected_version": None
}
def main():
project_root = sys.argv[1] if len(sys.argv) > 1 else "."
detector = ProjectDetector(project_root)
result = detector.detect()
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Git operations wrapper with cross-platform support.
Handles: commit, tag, push, branch detection
"""
import subprocess
import sys
from pathlib import Path
from typing import Optional, List
class GitOperations:
def __init__(self, repo_path: str = "."):
self.repo_path = Path(repo_path).resolve()
def run_command(
self,
cmd: List[str],
check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run git command with proper error handling."""
try:
result = subprocess.run(
cmd,
cwd=self.repo_path,
capture_output=True,
text=True,
check=check,
encoding='utf-8'
)
return result
except subprocess.CalledProcessError as e:
print(f"Git command failed: {' '.join(cmd)}", file=sys.stderr)
print(f"Error: {e.stderr}", file=sys.stderr)
raise
def get_current_branch(self) -> str:
"""Get current git branch name."""
result = self.run_command(['git', 'branch', '--show-current'])
return result.stdout.strip()
def get_status(self) -> str:
"""Get git status."""
result = self.run_command(['git', 'status', '--porcelain'])
return result.stdout
def add_files(self, files: Optional[List[str]] = None) -> bool:
"""Stage files for commit."""
if files:
self.run_command(['git', 'add'] + files)
else:
self.run_command(['git', 'add', '-A'])
print("✓ Files staged for commit")
return True
def commit(self, message: str) -> bool:
"""Create git commit."""
self.run_command(['git', 'commit', '-m', message])
print(f"✓ Committed: {message.split('\n')[0][:60]}...")
return True
def create_tag(self, tag_name: str, message: Optional[str] = None) -> bool:
"""Create annotated git tag."""
cmd = ['git', 'tag', '-a', tag_name]
if message:
cmd.extend(['-m', message])
else:
cmd.extend(['-m', f"Release {tag_name}"])
self.run_command(cmd)
print(f"✓ Created tag: {tag_name}")
return True
def push(
self,
remote: str = 'origin',
branch: Optional[str] = None,
tags: bool = False) -> bool:
"""Push commits and/or tags to remote."""
if tags:
# Push tags
cmd = ['git', 'push', remote, '--tags']
self.run_command(cmd)
print(f"✓ Pushed tags to {remote}")
else:
# Push branch
if not branch:
branch = self.get_current_branch()
cmd = ['git', 'push', remote, branch]
self.run_command(cmd)
print(f"✓ Pushed {branch} to {remote}")
return True
def tag_exists(self, tag_name: str) -> bool:
"""Check if tag exists."""
result = self.run_command(['git', 'tag', '-l', tag_name], check=False)
return bool(result.stdout.strip())
def main():
if len(sys.argv) < 2:
print("Usage:", file=sys.stderr)
print(" python git_operations.py commit <message>", file=sys.stderr)
print(
" python git_operations.py tag <tag_name> [message]",
file=sys.stderr)
print(" python git_operations.py push [branch]", file=sys.stderr)
print(" python git_operations.py push-tags", file=sys.stderr)
sys.exit(1)
action = sys.argv[1]
git = GitOperations()
try:
if action == 'commit':
if len(sys.argv) < 3:
print("Error: Commit message required", file=sys.stderr)
sys.exit(1)
message = sys.argv[2]
git.add_files()
git.commit(message)
elif action == 'tag':
if len(sys.argv) < 3:
print("Error: Tag name required", file=sys.stderr)
sys.exit(1)
tag_name = sys.argv[2]
message = sys.argv[3] if len(sys.argv) > 3 else None
git.create_tag(tag_name, message)
elif action == 'push':
branch = sys.argv[2] if len(sys.argv) > 2 else None
git.push(branch=branch)
elif action == 'push-tags':
git.push(tags=True)
else:
print(f"Error: Unknown action '{action}'", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Sync Unity version from version.json to ProjectSettings.asset.
This ensures version.json is the single source of truth.
"""
import json
import re
import sys
from pathlib import Path
def sync_unity_version(project_root: str = ".") -> bool:
"""Sync version from version.json to ProjectSettings.asset."""
root = Path(project_root).resolve()
version_json_path = root / "version.json"
project_settings_path = root / "ProjectSettings" / "ProjectSettings.asset"
# Check if files exist
if not version_json_path.exists():
print(
f"Error: version.json not found at {version_json_path}",
file=sys.stderr)
return False
if not project_settings_path.exists():
print(
f"Error: ProjectSettings.asset not found at "
f"{project_settings_path}",
file=sys.stderr)
return False
try:
# Read version from version.json
version_data = json.loads(
version_json_path.read_text(
encoding='utf-8'))
version = version_data.get('version')
if not version:
print(
"Error: 'version' field not found in version.json",
file=sys.stderr)
return False
# Read ProjectSettings.asset
content = project_settings_path.read_text(encoding='utf-8')
# Find and replace bundleVersion
pattern = r'(bundleVersion:\s*)(.+)'
replacement = r'\g<1>' + version
if not re.search(pattern, content):
print(
"Error: bundleVersion not found in ProjectSettings.asset",
file=sys.stderr)
return False
# Update content
new_content = re.sub(pattern, replacement, content)
if new_content == content:
print(f"✓ Version already synced: {version}")
return True
# Write back
project_settings_path.write_text(new_content, encoding='utf-8')
print(f"✓ Synced Unity version: {version}")
print(" version.json → ProjectSettings.asset")
return True
except (json.JSONDecodeError, IOError, UnicodeDecodeError) as e:
print(f"Error syncing Unity version: {e}", file=sys.stderr)
return False
def main():
project_root = sys.argv[1] if len(sys.argv) > 1 else "."
if not sync_unity_version(project_root):
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Universal version updater supporting multiple file formats.
Supports: JSON, TOML, YAML, plain text, Unity asset files, etc.
"""
import json
import re
import sys
from pathlib import Path
from typing import List
class VersionUpdater:
def __init__(self):
self.updated_files: List[str] = []
def update(self, file_path: str, new_version: str) -> bool:
"""Update version in file based on its format."""
path = Path(file_path)
if not path.exists():
print(f"Error: File not found: {file_path}", file=sys.stderr)
return False
# Detect file type and update accordingly
if path.suffix == '.json':
return self._update_json(path, new_version)
elif path.suffix == '.toml':
return self._update_toml(path, new_version)
elif path.suffix in ['.yaml', '.yml']:
return self._update_yaml(path, new_version)
elif path.name == 'ProjectSettings.asset':
return self._update_unity_asset(path, new_version)
elif path.suffix == '.uproject':
return self._update_unreal_uproject(path, new_version)
elif path.name in ['VERSION', 'version.txt']:
return self._update_plain_text(path, new_version)
else:
print(f"Warning: Unknown file type: {file_path}", file=sys.stderr)
return False
def _update_json(self, path: Path, new_version: str) -> bool:
"""Update version in JSON file."""
try:
data = json.loads(path.read_text(encoding='utf-8'))
# Update version field
if 'version' in data:
old_version = data['version']
data['version'] = new_version
print(f"Updated {path.name}: {old_version}{new_version}")
else:
data['version'] = new_version
print(f"Added version to {path.name}: {new_version}")
# Write back with proper formatting
path.write_text(
json.dumps(
data,
indent=2,
ensure_ascii=False) +
'\n',
encoding='utf-8')
self.updated_files.append(str(path))
return True
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def _update_toml(self, path: Path, new_version: str) -> bool:
"""Update version in TOML file."""
try:
content = path.read_text(encoding='utf-8')
# Try to find and replace version
patterns = [
(r'(version\s*=\s*["\'])([^"\']+)(["\'])',
r'\g<1>' + new_version + r'\g<3>'),
(r'(\[project\].*?version\s*=\s*["\'])([^"\']+)(["\'])',
r'\g<1>' + new_version + r'\g<3>'),
]
updated = False
for pattern, replacement in patterns:
if re.search(pattern, content, re.DOTALL):
content = re.sub(
pattern, replacement, content, flags=re.DOTALL)
updated = True
break
if updated:
path.write_text(content, encoding='utf-8')
self.updated_files.append(str(path))
print(f"Updated {path.name}{new_version}")
return True
else:
print(
f"Warning: Could not find version in {
path.name}", file=sys.stderr)
return False
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def _update_yaml(self, path: Path, new_version: str) -> bool:
"""Update version in YAML file."""
try:
content = path.read_text(encoding='utf-8')
# Replace version field
pattern = r'(version:\s*["\']?)([^"\'\n]+)(["\']?)'
replacement = r'\g<1>' + new_version + r'\g<3>'
if re.search(pattern, content):
content = re.sub(pattern, replacement, content)
path.write_text(content, encoding='utf-8')
self.updated_files.append(str(path))
print(f"Updated {path.name}{new_version}")
return True
else:
print(
f"Warning: Could not find version in {
path.name}", file=sys.stderr)
return False
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def _update_unity_asset(self, path: Path, new_version: str) -> bool:
"""Update bundleVersion in Unity ProjectSettings.asset."""
try:
content = path.read_text(encoding='utf-8')
# Replace bundleVersion
pattern = r'(bundleVersion:\s*)(.+)'
replacement = r'\g<1>' + new_version
if re.search(pattern, content):
old_content = content
content = re.sub(pattern, replacement, content)
if content != old_content:
path.write_text(content, encoding='utf-8')
self.updated_files.append(str(path))
print(
f"Updated Unity ProjectSettings.asset → {new_version}")
return True
print(
f"Warning: Could not find bundleVersion in {
path.name}", file=sys.stderr)
return False
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def _update_unreal_uproject(self, path: Path, new_version: str) -> bool:
"""Update version in Unreal .uproject file."""
try:
data = json.loads(path.read_text(encoding='utf-8'))
# Unreal uses EngineAssociation or custom Version field
if 'Version' in data:
old_version = data['Version']
data['Version'] = new_version
print(f"Updated {path.name}: {old_version}{new_version}")
else:
data['Version'] = new_version
print(f"Added Version to {path.name}: {new_version}")
path.write_text(
json.dumps(
data,
indent=4,
ensure_ascii=False) +
'\n',
encoding='utf-8')
self.updated_files.append(str(path))
return True
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def _update_plain_text(self, path: Path, new_version: str) -> bool:
"""Update plain text version file."""
try:
path.write_text(new_version + '\n', encoding='utf-8')
self.updated_files.append(str(path))
print(f"Updated {path.name}{new_version}")
return True
except Exception as e:
print(f"Error updating {path}: {e}", file=sys.stderr)
return False
def main():
if len(sys.argv) < 3:
print(
"Usage: python update_version.py <file_path> <new_version>",
file=sys.stderr)
print(
" python update_version.py "
"<file1> <file2> ... <new_version>",
file=sys.stderr)
sys.exit(1)
files = sys.argv[1:-1]
new_version = sys.argv[-1]
updater = VersionUpdater()
success_count = 0
for file_path in files:
if updater.update(file_path, new_version):
success_count += 1
print(f"\nUpdated {success_count}/{len(files)} files successfully")
if success_count < len(files):
sys.exit(1)
if __name__ == "__main__":
main()