Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:20:25 +08:00
commit 0d6226e0d8
69 changed files with 20934 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env bash
# Script: body-composer.sh
# Purpose: Compose commit message body with proper formatting and wrapping
# Author: Git Commit Assistant Plugin
# Version: 1.0.0
#
# Usage:
# export CHANGES="change1,change2,change3"
# export WRAP_LENGTH=72
# export FORMAT=bullets
# ./body-composer.sh
#
# Environment Variables:
# CHANGES - Comma-separated list of changes or file paths
# WRAP_LENGTH - Line wrap length (default: 72)
# FORMAT - Output format: bullets or paragraphs (default: bullets)
# WHY_CONTEXT - Optional context about why changes were made
#
# Returns:
# Formatted body text to stdout
# JSON summary to stderr (optional)
#
# Exit Codes:
# 0 - Success
# 1 - Invalid input
# 2 - Processing error
set -euo pipefail
# Default values
WRAP_LENGTH="${WRAP_LENGTH:-72}"
FORMAT="${FORMAT:-bullets}"
WHY_CONTEXT="${WHY_CONTEXT:-}"
# Validate CHANGES is provided
if [ -z "${CHANGES:-}" ]; then
echo "ERROR: CHANGES environment variable is required" >&2
exit 1
fi
# Validate FORMAT
if [ "$FORMAT" != "bullets" ] && [ "$FORMAT" != "paragraphs" ]; then
echo "ERROR: FORMAT must be 'bullets' or 'paragraphs'" >&2
exit 1
fi
# Validate WRAP_LENGTH
if ! [[ "$WRAP_LENGTH" =~ ^[0-9]+$ ]]; then
echo "ERROR: WRAP_LENGTH must be a positive integer" >&2
exit 1
fi
# Function to convert to imperative mood
convert_to_imperative() {
local text="$1"
# Common past tense -> imperative
text=$(echo "$text" | sed -E 's/\b(added|adds)\b/add/gi')
text=$(echo "$text" | sed -E 's/\b(fixed|fixes)\b/fix/gi')
text=$(echo "$text" | sed -E 's/\b(updated|updates)\b/update/gi')
text=$(echo "$text" | sed -E 's/\b(removed|removes)\b/remove/gi')
text=$(echo "$text" | sed -E 's/\b(changed|changes)\b/change/gi')
text=$(echo "$text" | sed -E 's/\b(improved|improves)\b/improve/gi')
text=$(echo "$text" | sed -E 's/\b(refactored|refactors)\b/refactor/gi')
text=$(echo "$text" | sed -E 's/\b(implemented|implements)\b/implement/gi')
text=$(echo "$text" | sed -E 's/\b(created|creates)\b/create/gi')
text=$(echo "$text" | sed -E 's/\b(deleted|deletes)\b/delete/gi')
# Lowercase first letter
text="$(echo "${text:0:1}" | tr '[:upper:]' '[:lower:]')${text:1}"
echo "$text"
}
# Function to wrap text at specified length
wrap_text() {
local text="$1"
local width="$2"
echo "$text" | fold -s -w "$width"
}
# Function to format file path as readable change
format_file_path() {
local filepath="$1"
# Extract filename and directory
local filename=$(basename "$filepath")
local dirname=$(dirname "$filepath")
# Remove extension
local name_no_ext="${filename%.*}"
# Convert to readable format
# Example: src/auth/oauth.js -> "add OAuth authentication module"
# Example: tests/unit/user.test.js -> "add user unit tests"
if [[ "$filepath" == *"/test/"* ]] || [[ "$filepath" == *"/tests/"* ]] || [[ "$filename" == *".test."* ]] || [[ "$filename" == *".spec."* ]]; then
echo "add ${name_no_ext} tests"
elif [[ "$dirname" == "." ]]; then
echo "update ${name_no_ext}"
else
# Extract meaningful part from path
local component=$(echo "$dirname" | sed 's|.*/||')
echo "update ${component} ${name_no_ext}"
fi
}
# Function to generate bullet points
generate_bullets() {
local changes_list="$1"
# Split by comma
IFS=',' read -ra items <<< "$changes_list"
local body=""
local bullet_count=0
for item in "${items[@]}"; do
# Trim whitespace
item=$(echo "$item" | xargs)
if [ -z "$item" ]; then
continue
fi
# Check if it's a file path
if [[ "$item" == *"/"* ]] || [[ "$item" == *"."* ]]; then
# Format as file path
item=$(format_file_path "$item")
fi
# Convert to imperative mood
item=$(convert_to_imperative "$item")
# Ensure first letter is capitalized for bullet
item="$(echo "${item:0:1}" | tr '[:lower:]' '[:upper:]')${item:1}"
# Wrap if needed (account for "- " prefix)
local max_width=$((WRAP_LENGTH - 2))
local wrapped=$(wrap_text "$item" "$max_width")
# Add bullet point
echo "$wrapped" | while IFS= read -r line; do
if [ "$bullet_count" -eq 0 ] || [ -z "$line" ]; then
body="${body}- ${line}\n"
else
body="${body} ${line}\n" # Indent continuation lines
fi
done
bullet_count=$((bullet_count + 1))
done
# Output body (remove trailing newline)
echo -ne "$body"
}
# Function to generate paragraphs
generate_paragraphs() {
local changes_list="$1"
# Split by comma and join into sentences
IFS=',' read -ra items <<< "$changes_list"
local body=""
for item in "${items[@]}"; do
# Trim whitespace
item=$(echo "$item" | xargs)
if [ -z "$item" ]; then
continue
fi
# Check if it's a file path
if [[ "$item" == *"/"* ]] || [[ "$item" == *"."* ]]; then
item=$(format_file_path "$item")
fi
# Convert to imperative mood
item=$(convert_to_imperative "$item")
# Ensure first letter is capitalized
item="$(echo "${item:0:1}" | tr '[:lower:]' '[:upper:]')${item:1}"
# Add to body
if [ -z "$body" ]; then
body="$item"
else
body="${body}. ${item}"
fi
done
# Add period at end if not present
if [[ ! "$body" =~ \.$ ]]; then
body="${body}."
fi
# Wrap text
wrapped=$(wrap_text "$body" "$WRAP_LENGTH")
echo "$wrapped"
}
# Function to add context (why)
add_context() {
local body="$1"
local context="$2"
if [ -z "$context" ]; then
echo "$body"
return
fi
# Ensure first letter is capitalized
context="$(echo "${context:0:1}" | tr '[:lower:]' '[:upper:]')${context:1}"
# Add period if not present
if [[ ! "$context" =~ \.$ ]]; then
context="${context}."
fi
# Wrap context
wrapped_context=$(wrap_text "$context" "$WRAP_LENGTH")
# Combine with blank line
echo -e "${body}\n\n${wrapped_context}"
}
# Main composition logic
compose_body() {
local body=""
# Generate based on format
if [ "$FORMAT" = "bullets" ]; then
body=$(generate_bullets "$CHANGES")
else
body=$(generate_paragraphs "$CHANGES")
fi
# Add context if provided
if [ -n "$WHY_CONTEXT" ]; then
body=$(add_context "$body" "$WHY_CONTEXT")
fi
echo "$body"
}
# Validate body
validate_body() {
local body="$1"
local line_count=$(echo "$body" | wc -l)
local longest_line=$(echo "$body" | awk '{ print length }' | sort -rn | head -1)
local bullet_count=$(echo "$body" | grep -c '^- ' || true)
local warnings=()
# Check longest line
if [ "$longest_line" -gt "$WRAP_LENGTH" ]; then
warnings+=("Line exceeds $WRAP_LENGTH characters ($longest_line chars)")
fi
# Check for empty lines at start
if echo "$body" | head -1 | grep -q '^$'; then
warnings+=("Body starts with empty line")
fi
# Output validation summary to stderr
{
echo "{"
echo " \"line_count\": $line_count,"
echo " \"longest_line\": $longest_line,"
echo " \"wrap_length\": $WRAP_LENGTH,"
echo " \"bullet_count\": $bullet_count,"
echo " \"format\": \"$FORMAT\","
echo " \"has_context\": $([ -n "$WHY_CONTEXT" ] && echo "true" || echo "false"),"
echo " \"warnings\": ["
for i in "${!warnings[@]}"; do
echo " \"${warnings[$i]}\"$([ $i -lt $((${#warnings[@]} - 1)) ] && echo "," || echo "")"
done
echo " ],"
echo " \"valid\": $([ ${#warnings[@]} -eq 0 ] && echo "true" || echo "false")"
echo "}"
} >&2
}
# Main execution
main() {
# Compose body
body=$(compose_body)
# Validate
validate_body "$body"
# Output body to stdout
echo "$body"
exit 0
}
# Run main
main

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
# Script: footer-builder.py
# Purpose: Build commit message footer with breaking changes and issue references
# Author: Git Commit Assistant Plugin
# Version: 1.0.0
#
# Usage:
# echo '{"breaking":"API changed","closes":"123,456"}' | ./footer-builder.py
# cat input.json | ./footer-builder.py
#
# Returns:
# JSON: {"footer": "...", "components": {...}, "valid": true}
#
# Exit Codes:
# 0 - Success
# 1 - Invalid input
# 2 - Processing error
import sys
import json
import re
import textwrap
def wrap_text(text, width=72, subsequent_indent=''):
"""Wrap text at specified width."""
wrapper = textwrap.TextWrapper(
width=width,
subsequent_indent=subsequent_indent,
break_long_words=False,
break_on_hyphens=False
)
return wrapper.fill(text)
def format_breaking_change(description):
"""Format breaking change notice."""
if not description:
return None
# Ensure BREAKING CHANGE is uppercase
# Wrap at 72 characters with continuation indentation
wrapped = wrap_text(
description,
width=72,
subsequent_indent=''
)
return f"BREAKING CHANGE: {wrapped}"
def parse_issue_numbers(issue_string):
"""Parse comma-separated issue numbers into list."""
if not issue_string:
return []
# Remove any # symbols
issue_string = issue_string.replace('#', '')
# Split by comma and clean
issues = [num.strip() for num in issue_string.split(',') if num.strip()]
# Validate all are numbers
valid_issues = []
for issue in issues:
if issue.isdigit():
valid_issues.append(issue)
else:
# Try to extract number
match = re.search(r'\d+', issue)
if match:
valid_issues.append(match.group())
return valid_issues
def format_issue_references(closes=None, fixes=None, refs=None):
"""Format issue references."""
lines = []
# Closes (for features/pull requests)
if closes:
issues = parse_issue_numbers(closes)
if issues:
if len(issues) == 1:
lines.append(f"Closes #{issues[0]}")
else:
# Format as comma-separated list
issue_refs = ', '.join([f"#{num}" for num in issues])
lines.append(f"Closes {issue_refs}")
# Fixes (for bug fixes)
if fixes:
issues = parse_issue_numbers(fixes)
if issues:
if len(issues) == 1:
lines.append(f"Fixes #{issues[0]}")
else:
issue_refs = ', '.join([f"#{num}" for num in issues])
lines.append(f"Fixes {issue_refs}")
# Refs (for related issues)
if refs:
issues = parse_issue_numbers(refs)
if issues:
if len(issues) == 1:
lines.append(f"Refs #{issues[0]}")
else:
issue_refs = ', '.join([f"#{num}" for num in issues])
lines.append(f"Refs {issue_refs}")
return lines
def format_metadata(reviewed=None, signed=None):
"""Format metadata like Reviewed-by and Signed-off-by."""
lines = []
if reviewed:
lines.append(f"Reviewed-by: {reviewed}")
if signed:
# Validate email format
if '@' in signed and '<' in signed and '>' in signed:
lines.append(f"Signed-off-by: {signed}")
else:
# Try to format properly
lines.append(f"Signed-off-by: {signed}")
return lines
def build_footer(data):
"""
Build commit message footer from input data.
Args:
data: dict with keys: breaking, closes, fixes, refs, reviewed, signed
Returns:
dict with footer, components, valid status
"""
# Extract parameters
breaking = data.get('breaking', '').strip()
closes = data.get('closes', '').strip()
fixes = data.get('fixes', '').strip()
refs = data.get('refs', '').strip()
reviewed = data.get('reviewed', '').strip()
signed = data.get('signed', '').strip()
# Check if any parameter provided
has_content = any([breaking, closes, fixes, refs, reviewed, signed])
if not has_content:
return {
'error': 'At least one footer component is required',
'footer': None,
'valid': False
}
# Build footer components
footer_lines = []
components = {
'breaking_change': False,
'closes_issues': 0,
'fixes_issues': 0,
'refs_issues': 0,
'reviewed_by': False,
'signed_off': False
}
# Breaking change (always first)
if breaking:
breaking_line = format_breaking_change(breaking)
if breaking_line:
footer_lines.append(breaking_line)
components['breaking_change'] = True
# Issue references
issue_lines = format_issue_references(closes, fixes, refs)
footer_lines.extend(issue_lines)
# Count issues
if closes:
components['closes_issues'] = len(parse_issue_numbers(closes))
if fixes:
components['fixes_issues'] = len(parse_issue_numbers(fixes))
if refs:
components['refs_issues'] = len(parse_issue_numbers(refs))
# Metadata
metadata_lines = format_metadata(reviewed, signed)
footer_lines.extend(metadata_lines)
if reviewed:
components['reviewed_by'] = True
if signed:
components['signed_off'] = True
# Join all lines
footer = '\n'.join(footer_lines)
# Validate footer
warnings = []
# Check breaking change format
if breaking and not footer.startswith('BREAKING CHANGE:'):
warnings.append('BREAKING CHANGE must be uppercase')
# Check issue reference format
for line in footer_lines:
if 'closes' in line.lower() and not line.startswith('Closes'):
warnings.append('Use "Closes" (capitalized)')
if 'fixes' in line.lower() and not line.startswith('Fixes'):
warnings.append('Use "Fixes" (capitalized)')
if 'refs' in line.lower() and not line.startswith('Refs'):
warnings.append('Use "Refs" (capitalized)')
# Check for proper issue number format
if any([closes, fixes, refs]):
# Make sure all issue numbers are valid
all_issues = parse_issue_numbers(closes) + parse_issue_numbers(fixes) + parse_issue_numbers(refs)
if not all_issues:
warnings.append('No valid issue numbers found')
# Build response
response = {
'footer': footer,
'components': components,
'line_count': len(footer_lines),
'has_breaking': components['breaking_change'],
'total_issues': components['closes_issues'] + components['fixes_issues'] + components['refs_issues'],
'warnings': warnings,
'valid': len(warnings) == 0
}
# Add quality score
score = 100
if not components['breaking_change'] and breaking:
score -= 10
if warnings:
score -= len(warnings) * 5
response['quality_score'] = max(0, score)
return response
def main():
"""Main entry point."""
try:
# Read JSON input from stdin
input_data = sys.stdin.read()
if not input_data or not input_data.strip():
print(json.dumps({
'error': 'No input provided',
'footer': None,
'valid': False
}))
sys.exit(1)
# Parse JSON
try:
data = json.loads(input_data)
except json.JSONDecodeError as e:
print(json.dumps({
'error': f'Invalid JSON: {str(e)}',
'footer': None,
'valid': False
}))
sys.exit(1)
# Build footer
result = build_footer(data)
# Output result
print(json.dumps(result, indent=2))
# Exit code based on result
if 'error' in result:
sys.exit(2)
elif not result.get('valid', False):
sys.exit(1)
else:
sys.exit(0)
except Exception as e:
print(json.dumps({
'error': f'Unexpected error: {str(e)}',
'footer': None,
'valid': False
}))
sys.exit(2)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env bash
# Script: message-validator.sh
# Purpose: Validate commit message against conventional commits standard
# Author: Git Commit Assistant Plugin
# Version: 1.0.0
#
# Usage:
# export MESSAGE="feat: add feature"
# export STRICT_MODE=false
# export MAX_SUBJECT=50
# export MAX_LINE=72
# ./message-validator.sh
#
# Environment Variables:
# MESSAGE - Commit message to validate (required)
# STRICT_MODE - Enable strict validation (default: false)
# MAX_SUBJECT - Maximum subject length (default: 50)
# MAX_LINE - Maximum body line length (default: 72)
#
# Returns:
# Validation report to stdout
# Exit code indicates validation status
#
# Exit Codes:
# 0 - Valid message
# 1 - Invalid message (warnings in normal mode, any issue in strict mode)
# 2 - Error (missing input, malformed message)
set -euo pipefail
# Default values
STRICT_MODE="${STRICT_MODE:-false}"
MAX_SUBJECT="${MAX_SUBJECT:-50}"
MAX_LINE="${MAX_LINE:-72}"
# Validation counters
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a SUGGESTIONS=()
# Score tracking
SCORE=100
# Validate MESSAGE is provided
if [ -z "${MESSAGE:-}" ]; then
echo "ERROR: MESSAGE environment variable is required" >&2
exit 2
fi
# Valid commit types
VALID_TYPES="feat fix docs style refactor perf test build ci chore revert"
# Function to check commit type
validate_type() {
local subject="$1"
# Extract type (before colon or parenthesis)
local type=$(echo "$subject" | grep -oP '^[a-z]+' || echo "")
if [ -z "$type" ]; then
ERRORS+=("No commit type found")
SCORE=$((SCORE - 20))
return 1
fi
# Check if type is valid
if ! echo "$VALID_TYPES" | grep -qw "$type"; then
ERRORS+=("Invalid commit type: '$type'")
ERRORS+=("Valid types: $VALID_TYPES")
SCORE=$((SCORE - 20))
return 1
fi
return 0
}
# Function to check scope format
validate_scope() {
local subject="$1"
# Check if scope exists
if echo "$subject" | grep -qP '^\w+\([^)]+\):'; then
local scope=$(echo "$subject" | grep -oP '^\w+\(\K[^)]+')
# Scope should be lowercase alphanumeric with hyphens
if ! echo "$scope" | grep -qP '^[a-z0-9-]+$'; then
WARNINGS+=("Scope should be lowercase alphanumeric with hyphens: '$scope'")
SCORE=$((SCORE - 5))
fi
fi
}
# Function to validate subject line
validate_subject() {
local subject="$1"
# Check format: type(scope): description or type: description
if ! echo "$subject" | grep -qP '^[a-z]+(\([a-z0-9-]+\))?: .+'; then
ERRORS+=("Subject does not match conventional commits format")
ERRORS+=("Expected: <type>(<scope>): <description> or <type>: <description>")
SCORE=$((SCORE - 30))
return 1
fi
# Validate type
validate_type "$subject"
# Validate scope if present
validate_scope "$subject"
# Extract description (after ": ")
local description=$(echo "$subject" | sed 's/^[^:]*: //')
# Check length
local length=${#subject}
if [ "$length" -gt "$MAX_SUBJECT" ]; then
if [ "$length" -gt 72 ]; then
ERRORS+=("Subject exceeds hard limit of 72 characters ($length chars)")
SCORE=$((SCORE - 30))
else
WARNINGS+=("Subject exceeds recommended $MAX_SUBJECT characters ($length chars)")
SUGGESTIONS+=("Consider shortening subject or moving details to body")
SCORE=$((SCORE - 10))
fi
fi
# Check for capital letter after colon
if echo "$description" | grep -qP '^[A-Z]'; then
WARNINGS+=("Description should not start with capital letter")
SUGGESTIONS+=("Use lowercase after colon: '$(echo "${description:0:1}" | tr '[:upper:]' '[:lower:]')${description:1}'")
SCORE=$((SCORE - 5))
fi
# Check for period at end
if [[ "$description" =~ \.$ ]]; then
WARNINGS+=("Subject should not end with period")
SUGGESTIONS+=("Remove period at end")
SCORE=$((SCORE - 3))
fi
# Check for imperative mood (simple heuristics)
if echo "$description" | grep -qP '\b(added|fixed|updated|removed|changed|improved|created|deleted)\b'; then
WARNINGS+=("Use imperative mood (add, fix, update) not past tense")
SCORE=$((SCORE - 5))
fi
if echo "$description" | grep -qP '\b(adds|fixes|updates|removes|changes|improves|creates|deletes)\b'; then
WARNINGS+=("Use imperative mood (add, fix, update) not present tense")
SCORE=$((SCORE - 5))
fi
# Check for vague descriptions
if echo "$description" | grep -qiP '\b(update|change|fix|improve)\s+(code|file|stuff|thing)\b'; then
SUGGESTIONS+=("Be more specific in description")
SCORE=$((SCORE - 5))
fi
return 0
}
# Function to validate body
validate_body() {
local body="$1"
if [ -z "$body" ]; then
return 0 # Body is optional
fi
# Check line lengths
while IFS= read -r line; do
local length=${#line}
if [ "$length" -gt "$MAX_LINE" ]; then
WARNINGS+=("Body line exceeds $MAX_LINE characters ($length chars)")
SCORE=$((SCORE - 3))
fi
done <<< "$body"
# Check for imperative mood in body
if echo "$body" | grep -qP '\b(added|fixed|updated|removed|changed|improved|created|deleted)\b'; then
SUGGESTIONS+=("Consider using imperative mood in body")
fi
return 0
}
# Function to validate footer
validate_footer() {
local footer="$1"
if [ -z "$footer" ]; then
return 0 # Footer is optional
fi
# Check for BREAKING CHANGE format
if echo "$footer" | grep -qi "breaking change"; then
if ! echo "$footer" | grep -q "^BREAKING CHANGE:"; then
ERRORS+=("Use 'BREAKING CHANGE:' (uppercase, singular) not 'breaking change'")
SCORE=$((SCORE - 15))
fi
fi
# Check for issue references
if echo "$footer" | grep -qiP '\b(close|fix|resolve)[sd]?\b'; then
# Check format
if ! echo "$footer" | grep -qP '^(Closes|Fixes|Resolves|Refs) #[0-9]'; then
WARNINGS+=("Issue references should use proper format: 'Closes #123'")
SUGGESTIONS+=("Capitalize keyword and use # prefix for issue numbers")
SCORE=$((SCORE - 5))
fi
fi
return 0
}
# Function to check overall structure
validate_structure() {
local message="$1"
# Count lines
local line_count=$(echo "$message" | wc -l)
# Split message into parts
local subject=$(echo "$message" | head -1)
local rest=$(echo "$message" | tail -n +2)
# Validate subject
validate_subject "$subject"
# If multi-line, check for blank line after subject
if [ "$line_count" -gt 1 ]; then
local second_line=$(echo "$message" | sed -n '2p')
if [ -n "$second_line" ]; then
ERRORS+=("Blank line required between subject and body")
SCORE=$((SCORE - 10))
fi
# Extract body (after blank line, before footer)
local body=""
local footer=""
local in_footer=false
while IFS= read -r line; do
# Check if line is footer token
if echo "$line" | grep -qP '^(BREAKING CHANGE:|Closes|Fixes|Resolves|Refs|Reviewed-by|Signed-off-by)'; then
in_footer=true
fi
if [ "$in_footer" = true ]; then
footer="${footer}${line}\n"
else
body="${body}${line}\n"
fi
done <<< "$rest"
# Remove leading blank line from body
body=$(echo -e "$body" | sed '1{/^$/d;}')
# Validate body and footer
validate_body "$body"
validate_footer "$footer"
fi
}
# Main validation logic
main() {
echo "COMMIT MESSAGE VALIDATION"
echo "═══════════════════════════════════════════════"
echo ""
echo "MESSAGE:"
echo "───────────────────────────────────────────────"
echo "$MESSAGE"
echo ""
# Perform validation
validate_structure "$MESSAGE"
# Calculate final status
local status="VALID"
if [ "${#ERRORS[@]}" -gt 0 ]; then
status="INVALID"
elif [ "$STRICT_MODE" = "true" ] && [ "${#WARNINGS[@]}" -gt 0 ]; then
status="INVALID"
fi
# Display results
echo "VALIDATION RESULTS:"
echo "───────────────────────────────────────────────"
if [ "${#ERRORS[@]}" -gt 0 ]; then
echo "✗ ERRORS:"
for error in "${ERRORS[@]}"; do
echo " - $error"
done
echo ""
fi
if [ "${#WARNINGS[@]}" -gt 0 ]; then
echo "⚠ WARNINGS:"
for warning in "${WARNINGS[@]}"; do
echo " - $warning"
done
echo ""
fi
if [ "${#SUGGESTIONS[@]}" -gt 0 ]; then
echo "💡 SUGGESTIONS:"
for suggestion in "${SUGGESTIONS[@]}"; do
echo " - $suggestion"
done
echo ""
fi
if [ "${#ERRORS[@]}" -eq 0 ] && [ "${#WARNINGS[@]}" -eq 0 ]; then
echo "✓ All checks passed"
echo ""
fi
echo "STATUS: $status"
echo "SCORE: $SCORE/100"
echo "STRICT MODE: $STRICT_MODE"
echo ""
echo "═══════════════════════════════════════════════"
# Exit based on status
if [ "$status" = "INVALID" ]; then
exit 1
else
exit 0
fi
}
# Run main
main

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
# Script: subject-generator.py
# Purpose: Generate conventional commit subject line with validation
# Author: Git Commit Assistant Plugin
# Version: 1.0.0
#
# Usage:
# echo '{"type":"feat","scope":"auth","description":"add OAuth"}' | ./subject-generator.py
# cat input.json | ./subject-generator.py
#
# Returns:
# JSON: {"subject": "feat(auth): add OAuth", "length": 22, "warnings": [], "suggestions": []}
#
# Exit Codes:
# 0 - Success
# 1 - Invalid input
# 2 - Validation error
import sys
import json
import re
def enforce_imperative_mood(text):
"""Convert common non-imperative forms to imperative mood."""
# Common past tense to imperative conversions
conversions = {
r'\badded\b': 'add',
r'\bfixed\b': 'fix',
r'\bupdated\b': 'update',
r'\bremoved\b': 'remove',
r'\bchanged\b': 'change',
r'\bimproved\b': 'improve',
r'\brefactored\b': 'refactor',
r'\bimplemented\b': 'implement',
r'\bcreated\b': 'create',
r'\bdeleted\b': 'delete',
r'\bmodified\b': 'modify',
r'\boptimized\b': 'optimize',
r'\bmoved\b': 'move',
r'\brenamed\b': 'rename',
r'\bcleaned\b': 'clean',
r'\bintroduced\b': 'introduce',
}
# Present tense (3rd person) to imperative
present_conversions = {
r'\badds\b': 'add',
r'\bfixes\b': 'fix',
r'\bupdates\b': 'update',
r'\bremoves\b': 'remove',
r'\bchanges\b': 'change',
r'\bimproves\b': 'improve',
r'\brefactors\b': 'refactor',
r'\bimplements\b': 'implement',
r'\bcreates\b': 'create',
r'\bdeletes\b': 'delete',
r'\bmodifies\b': 'modify',
r'\boptimizes\b': 'optimize',
r'\bmoves\b': 'move',
r'\brenames\b': 'rename',
r'\bcleans\b': 'clean',
r'\bintroduces\b': 'introduce',
}
original = text
# Apply conversions
for pattern, replacement in conversions.items():
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
for pattern, replacement in present_conversions.items():
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
# Track if changes were made
changed = (original != text)
return text, changed
def check_capitalization(text):
"""Check if description starts with lowercase (should not be capitalized)."""
if not text:
return True, []
warnings = []
if text[0].isupper():
warnings.append({
'type': 'capitalization',
'message': 'Description should start with lowercase',
'current': text,
'suggested': text[0].lower() + text[1:]
})
return False, warnings
return True, warnings
def check_period_at_end(text):
"""Check if description ends with period (should not)."""
warnings = []
if text.endswith('.'):
warnings.append({
'type': 'punctuation',
'message': 'Subject should not end with period',
'current': text,
'suggested': text[:-1]
})
return False, warnings
return True, warnings
def shorten_description(description, max_length, type_scope_part):
"""Attempt to shorten description to fit within max_length."""
# Calculate available space for description
prefix_length = len(type_scope_part) + 2 # +2 for ": "
available_length = max_length - prefix_length
if len(description) <= available_length:
return description, []
suggestions = []
# Strategy 1: Remove filler words
filler_words = ['a', 'an', 'the', 'some', 'very', 'really', 'just', 'quite']
shortened = description
for word in filler_words:
shortened = re.sub(r'\b' + word + r'\b\s*', '', shortened, flags=re.IGNORECASE)
shortened = shortened.strip()
if len(shortened) <= available_length:
suggestions.append({
'strategy': 'remove_filler',
'description': shortened,
'saved': len(description) - len(shortened)
})
return shortened, suggestions
# Strategy 2: Truncate with ellipsis (not recommended but possible)
if available_length > 3:
truncated = description[:available_length - 3] + '...'
suggestions.append({
'strategy': 'truncate',
'description': truncated,
'warning': 'Truncation loses information - consider moving details to body'
})
# Strategy 3: Suggest moving to body
suggestions.append({
'strategy': 'move_to_body',
'description': description[:available_length],
'remaining': description[available_length:],
'warning': 'Move detailed information to commit body'
})
return description, suggestions
def generate_subject(data):
"""
Generate commit subject line from input data.
Args:
data: dict with keys: type, scope (optional), description, max_length (optional)
Returns:
dict with subject, length, warnings, suggestions
"""
# Extract parameters
commit_type = data.get('type', '').strip().lower()
scope = data.get('scope', '').strip()
description = data.get('description', '').strip()
max_length = int(data.get('max_length', 50))
# Validate required fields
if not commit_type:
return {
'error': 'type is required',
'subject': None
}
if not description:
return {
'error': 'description is required',
'subject': None
}
# Validate type
valid_types = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
if commit_type not in valid_types:
return {
'error': f'Invalid type "{commit_type}". Valid types: {", ".join(valid_types)}',
'subject': None
}
# Enforce imperative mood
original_description = description
description, mood_changed = enforce_imperative_mood(description)
# Ensure lowercase after colon
if description and description[0].isupper():
description = description[0].lower() + description[1:]
# Remove period at end
if description.endswith('.'):
description = description[:-1]
# Build type(scope) part
if scope:
type_scope_part = f"{commit_type}({scope})"
else:
type_scope_part = commit_type
# Build initial subject
subject = f"{type_scope_part}: {description}"
subject_length = len(subject)
# Collect warnings and suggestions
warnings = []
suggestions = []
# Check mood change
if mood_changed:
warnings.append({
'type': 'mood',
'message': 'Changed to imperative mood',
'original': original_description,
'corrected': description
})
# Check length
if subject_length > max_length:
warnings.append({
'type': 'length',
'message': f'Subject exceeds {max_length} characters ({subject_length} chars)',
'length': subject_length,
'max': max_length,
'excess': subject_length - max_length
})
# Try to shorten
shortened_desc, shorten_suggestions = shorten_description(description, max_length, type_scope_part)
if shorten_suggestions:
suggestions.extend(shorten_suggestions)
# If we successfully shortened, update subject
if len(shortened_desc) < len(description):
alternative_subject = f"{type_scope_part}: {shortened_desc}"
if len(alternative_subject) <= max_length:
suggestions.append({
'type': 'shortened_subject',
'subject': alternative_subject,
'saved': subject_length - len(alternative_subject)
})
# Warning if close to limit
elif subject_length > 45 and max_length == 50:
suggestions.append({
'type': 'near_limit',
'message': f'Subject is close to {max_length} character limit ({subject_length} chars)'
})
# Check for common issues
if ' and ' in description or ' & ' in description:
suggestions.append({
'type': 'multiple_changes',
'message': 'Subject mentions multiple changes - consider splitting into multiple commits or using bullet points in body'
})
# Check for filler words
filler_pattern = r'\b(just|very|really|quite|some)\b'
if re.search(filler_pattern, description, re.IGNORECASE):
cleaned = re.sub(filler_pattern, '', description, flags=re.IGNORECASE)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
suggestions.append({
'type': 'filler_words',
'message': 'Remove filler words for clarity',
'current': description,
'suggested': cleaned
})
# Build response
response = {
'subject': subject,
'length': subject_length,
'max_length': max_length,
'type': commit_type,
'scope': scope if scope else None,
'description': description,
'valid': subject_length <= max_length and subject_length <= 72, # 72 is hard limit
'warnings': warnings,
'suggestions': suggestions
}
# Add quality score
score = 100
if subject_length > max_length:
score -= 20
if subject_length > 72:
score -= 30 # Major penalty for exceeding hard limit
if mood_changed:
score -= 5
if len(warnings) > 0:
score -= len(warnings) * 3
response['quality_score'] = max(0, score)
return response
def main():
"""Main entry point."""
try:
# Read JSON input from stdin
input_data = sys.stdin.read()
if not input_data or not input_data.strip():
print(json.dumps({
'error': 'No input provided',
'subject': None
}))
sys.exit(1)
# Parse JSON
try:
data = json.loads(input_data)
except json.JSONDecodeError as e:
print(json.dumps({
'error': f'Invalid JSON: {str(e)}',
'subject': None
}))
sys.exit(1)
# Generate subject
result = generate_subject(data)
# Output result
print(json.dumps(result, indent=2))
# Exit code based on result
if 'error' in result:
sys.exit(2)
elif not result.get('valid', False):
sys.exit(1)
else:
sys.exit(0)
except Exception as e:
print(json.dumps({
'error': f'Unexpected error: {str(e)}',
'subject': None
}))
sys.exit(2)
if __name__ == '__main__':
main()