Initial commit
This commit is contained in:
259
commands/commit-best-practices/.scripts/amend-safety.sh
Executable file
259
commands/commit-best-practices/.scripts/amend-safety.sh
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env bash
|
||||
################################################################################
|
||||
# Amend Safety Checker Script
|
||||
#
|
||||
# Purpose: Check if it's safe to amend the last commit
|
||||
# Version: 1.0.0
|
||||
# Usage: ./amend-safety.sh
|
||||
# Returns: JSON with safety analysis
|
||||
# Exit Codes:
|
||||
# 0 = Safe to amend
|
||||
# 1 = Unsafe to amend
|
||||
# 2 = Warning (proceed with caution)
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
################################################################################
|
||||
# Check if commit is pushed to remote
|
||||
################################################################################
|
||||
check_not_pushed() {
|
||||
local status="fail"
|
||||
local message=""
|
||||
|
||||
# Check if we have upstream tracking
|
||||
if ! git rev-parse --abbrev-ref --symbolic-full-name @{upstream} &>/dev/null; then
|
||||
status="pass"
|
||||
message="No upstream branch (commit is local only)"
|
||||
else
|
||||
# Check if HEAD commit exists on upstream
|
||||
local upstream_branch
|
||||
upstream_branch=$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream})
|
||||
|
||||
# Get commits that are local only
|
||||
local local_commits
|
||||
local_commits=$(git log "$upstream_branch"..HEAD --oneline 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$local_commits" ]]; then
|
||||
status="pass"
|
||||
message="Commit not pushed to $upstream_branch"
|
||||
else
|
||||
status="fail"
|
||||
message="Commit already pushed to $upstream_branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check if current user is the commit author
|
||||
################################################################################
|
||||
check_same_author() {
|
||||
local status="fail"
|
||||
local message=""
|
||||
|
||||
# Get current user email
|
||||
local current_user
|
||||
current_user=$(git config user.email)
|
||||
|
||||
# Get last commit author email
|
||||
local commit_author
|
||||
commit_author=$(git log -1 --format='%ae')
|
||||
|
||||
if [[ "$current_user" == "$commit_author" ]]; then
|
||||
status="pass"
|
||||
message="You are the commit author"
|
||||
else
|
||||
status="fail"
|
||||
message="Different author: $commit_author (you are $current_user)"
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check if on a safe branch (not main/master)
|
||||
################################################################################
|
||||
check_safe_branch() {
|
||||
local status="pass"
|
||||
local message=""
|
||||
|
||||
# Get current branch
|
||||
local branch
|
||||
branch=$(git branch --show-current)
|
||||
|
||||
# List of protected branches
|
||||
local protected_branches=("main" "master" "develop" "production" "release")
|
||||
|
||||
for protected in "${protected_branches[@]}"; do
|
||||
if [[ "$branch" == "$protected" ]]; then
|
||||
status="warn"
|
||||
message="On protected branch: $branch (amending discouraged)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$status" == "pass" ]]; then
|
||||
message="On feature branch: $branch"
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check if collaborators might have this commit
|
||||
################################################################################
|
||||
check_collaborators() {
|
||||
local status="pass"
|
||||
local message="Solo work on branch"
|
||||
|
||||
# This is a heuristic check - actual collaboration is hard to detect
|
||||
# We check if:
|
||||
# 1. Remote branch exists
|
||||
# 2. There are other commits on the remote not in local
|
||||
|
||||
if git rev-parse --abbrev-ref --symbolic-full-name @{upstream} &>/dev/null; then
|
||||
local upstream_branch
|
||||
upstream_branch=$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream})
|
||||
|
||||
# Check if there are remote commits we don't have
|
||||
local remote_commits
|
||||
remote_commits=$(git log HEAD.."$upstream_branch" --oneline 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$remote_commits" ]]; then
|
||||
status="warn"
|
||||
message="Remote has commits you don't have - possible collaboration"
|
||||
else
|
||||
# Check if branch is shared (exists on remote)
|
||||
local remote_name
|
||||
remote_name=$(echo "$upstream_branch" | cut -d/ -f1)
|
||||
|
||||
local branch_name
|
||||
branch_name=$(git branch --show-current)
|
||||
|
||||
if git ls-remote --heads "$remote_name" "$branch_name" | grep -q "$branch_name"; then
|
||||
status="warn"
|
||||
message="Branch exists on remote - collaborators may have pulled"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Determine overall recommendation
|
||||
################################################################################
|
||||
determine_recommendation() {
|
||||
local not_pushed="$1"
|
||||
local same_author="$2"
|
||||
local safe_branch="$3"
|
||||
local collaborators="$4"
|
||||
|
||||
# UNSAFE conditions (critical failures)
|
||||
if [[ "$not_pushed" == "fail" ]] || [[ "$same_author" == "fail" ]]; then
|
||||
echo "unsafe"
|
||||
return
|
||||
fi
|
||||
|
||||
# WARNING conditions (proceed with caution)
|
||||
if [[ "$safe_branch" == "warn" ]] || [[ "$collaborators" == "warn" ]]; then
|
||||
echo "warning"
|
||||
return
|
||||
fi
|
||||
|
||||
# SAFE (all checks pass)
|
||||
echo "safe"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main execution
|
||||
################################################################################
|
||||
main() {
|
||||
# Verify we're in a git repository
|
||||
if ! git rev-parse --git-dir &>/dev/null; then
|
||||
echo "{\"error\": \"Not a git repository\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Check if there are any commits
|
||||
if ! git rev-parse HEAD &>/dev/null 2>&1; then
|
||||
echo "{\"error\": \"No commits to amend (empty repository)\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Run all checks
|
||||
local not_pushed_result
|
||||
local same_author_result
|
||||
local safe_branch_result
|
||||
local collaborators_result
|
||||
|
||||
not_pushed_result=$(check_not_pushed)
|
||||
same_author_result=$(check_same_author)
|
||||
safe_branch_result=$(check_safe_branch)
|
||||
collaborators_result=$(check_collaborators)
|
||||
|
||||
# Extract status values for recommendation
|
||||
local not_pushed_status
|
||||
local same_author_status
|
||||
local safe_branch_status
|
||||
local collaborators_status
|
||||
|
||||
not_pushed_status=$(echo "$not_pushed_result" | grep -o '"status": "[^"]*"' | cut -d'"' -f4)
|
||||
same_author_status=$(echo "$same_author_result" | grep -o '"status": "[^"]*"' | cut -d'"' -f4)
|
||||
safe_branch_status=$(echo "$safe_branch_result" | grep -o '"status": "[^"]*"' | cut -d'"' -f4)
|
||||
collaborators_status=$(echo "$collaborators_result" | grep -o '"status": "[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
# Determine overall recommendation
|
||||
local recommendation
|
||||
recommendation=$(determine_recommendation "$not_pushed_status" "$same_author_status" "$safe_branch_status" "$collaborators_status")
|
||||
|
||||
# Determine safe boolean
|
||||
local safe="false"
|
||||
if [[ "$recommendation" == "safe" ]]; then
|
||||
safe="true"
|
||||
fi
|
||||
|
||||
# Get commit info
|
||||
local commit_sha
|
||||
local commit_author
|
||||
local branch
|
||||
|
||||
commit_sha=$(git rev-parse --short HEAD)
|
||||
commit_author=$(git log -1 --format='%an <%ae>')
|
||||
branch=$(git branch --show-current)
|
||||
|
||||
# Build JSON output
|
||||
cat <<EOF
|
||||
{
|
||||
"safe": $safe,
|
||||
"recommendation": "$recommendation",
|
||||
"commit": "$commit_sha",
|
||||
"author": "$commit_author",
|
||||
"branch": "$branch",
|
||||
"checks": {
|
||||
"not_pushed": $not_pushed_result,
|
||||
"same_author": $same_author_result,
|
||||
"safe_branch": $safe_branch_result,
|
||||
"collaborators": $collaborators_result
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Exit with appropriate code
|
||||
case "$recommendation" in
|
||||
safe)
|
||||
exit 0
|
||||
;;
|
||||
warning)
|
||||
exit 2
|
||||
;;
|
||||
unsafe)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Execute main function
|
||||
main "$@"
|
||||
345
commands/commit-best-practices/.scripts/commit-reviewer.py
Executable file
345
commands/commit-best-practices/.scripts/commit-reviewer.py
Executable file
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
################################################################################
|
||||
# Commit Reviewer Script
|
||||
#
|
||||
# Purpose: Analyze commit quality including message, changes, and atomicity
|
||||
# Version: 1.0.0
|
||||
# Usage: ./commit-reviewer.py <commit-sha>
|
||||
# Returns: JSON with comprehensive quality analysis
|
||||
# Exit Codes:
|
||||
# 0 = Success
|
||||
# 1 = Commit not found
|
||||
# 2 = Script execution error
|
||||
################################################################################
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
from typing import Dict, List, Tuple, Any
|
||||
|
||||
################################################################################
|
||||
# Git operations
|
||||
################################################################################
|
||||
|
||||
def git_command(args: List[str]) -> str:
|
||||
"""Execute git command and return output"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git'] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
return ""
|
||||
|
||||
def commit_exists(sha: str) -> bool:
|
||||
"""Check if commit exists"""
|
||||
result = git_command(['rev-parse', '--verify', sha])
|
||||
return bool(result)
|
||||
|
||||
def get_commit_info(sha: str) -> Dict[str, str]:
|
||||
"""Get commit metadata"""
|
||||
return {
|
||||
'sha': git_command(['rev-parse', sha]),
|
||||
'author': git_command(['log', '-1', '--format=%an <%ae>', sha]),
|
||||
'date': git_command(['log', '-1', '--format=%ad', '--date=short', sha]),
|
||||
'subject': git_command(['log', '-1', '--format=%s', sha]),
|
||||
'body': git_command(['log', '-1', '--format=%b', sha]),
|
||||
}
|
||||
|
||||
def get_commit_stats(sha: str) -> Dict[str, int]:
|
||||
"""Get commit statistics"""
|
||||
stats_raw = git_command(['show', '--stat', '--format=', sha])
|
||||
|
||||
files_changed = 0
|
||||
insertions = 0
|
||||
deletions = 0
|
||||
test_files = 0
|
||||
doc_files = 0
|
||||
|
||||
for line in stats_raw.split('\n'):
|
||||
if '|' in line:
|
||||
files_changed += 1
|
||||
filename = line.split('|')[0].strip()
|
||||
|
||||
# Count test files
|
||||
if 'test' in filename.lower() or 'spec' in filename.lower():
|
||||
test_files += 1
|
||||
|
||||
# Count doc files
|
||||
if filename.endswith('.md') or 'doc' in filename.lower():
|
||||
doc_files += 1
|
||||
|
||||
# Parse summary line: "5 files changed, 234 insertions(+), 12 deletions(-)"
|
||||
if 'insertion' in line:
|
||||
match = re.search(r'(\d+) insertion', line)
|
||||
if match:
|
||||
insertions = int(match.group(1))
|
||||
|
||||
if 'deletion' in line:
|
||||
match = re.search(r'(\d+) deletion', line)
|
||||
if match:
|
||||
deletions = int(match.group(1))
|
||||
|
||||
return {
|
||||
'files_changed': files_changed,
|
||||
'insertions': insertions,
|
||||
'deletions': deletions,
|
||||
'test_files': test_files,
|
||||
'doc_files': doc_files,
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Message analysis
|
||||
################################################################################
|
||||
|
||||
def analyze_message(subject: str, body: str) -> Dict[str, Any]:
|
||||
"""Analyze commit message quality"""
|
||||
|
||||
# Check conventional commits format
|
||||
conventional_pattern = r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9\-]+\))?: .+'
|
||||
is_conventional = bool(re.match(conventional_pattern, subject, re.IGNORECASE))
|
||||
|
||||
# Extract type and scope if conventional
|
||||
commit_type = None
|
||||
commit_scope = None
|
||||
|
||||
if is_conventional:
|
||||
match = re.match(r'^([a-z]+)(?:\(([a-z0-9\-]+)\))?: ', subject, re.IGNORECASE)
|
||||
if match:
|
||||
commit_type = match.group(1).lower()
|
||||
commit_scope = match.group(2) if match.group(2) else None
|
||||
|
||||
# Check subject length
|
||||
subject_length = len(subject)
|
||||
subject_ok = subject_length <= 50
|
||||
|
||||
# Check imperative mood (basic heuristics)
|
||||
imperative_verbs = ['add', 'fix', 'update', 'remove', 'refactor', 'improve', 'implement']
|
||||
past_tense_patterns = ['added', 'fixed', 'updated', 'removed', 'refactored', 'improved', 'implemented']
|
||||
|
||||
subject_lower = subject.lower()
|
||||
uses_imperative = any(subject_lower.startswith(verb) for verb in imperative_verbs)
|
||||
uses_past_tense = any(pattern in subject_lower for pattern in past_tense_patterns)
|
||||
|
||||
# Check if body exists and is useful
|
||||
has_body = bool(body.strip())
|
||||
body_lines = body.strip().split('\n') if has_body else []
|
||||
body_line_count = len([line for line in body_lines if line.strip()])
|
||||
|
||||
# Body quality assessment
|
||||
has_explanation = body_line_count > 2
|
||||
uses_bullets = any(line.strip().startswith(('-', '*', '•')) for line in body_lines)
|
||||
|
||||
return {
|
||||
'subject': subject,
|
||||
'body': body if has_body else None,
|
||||
'subject_length': subject_length,
|
||||
'subject_ok': subject_ok,
|
||||
'has_body': has_body,
|
||||
'body_line_count': body_line_count,
|
||||
'conventional': is_conventional,
|
||||
'type': commit_type,
|
||||
'scope': commit_scope,
|
||||
'imperative': uses_imperative and not uses_past_tense,
|
||||
'explanation': has_explanation,
|
||||
'uses_bullets': uses_bullets,
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Atomicity analysis
|
||||
################################################################################
|
||||
|
||||
def analyze_atomicity(sha: str, stats: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""Analyze if commit is atomic"""
|
||||
|
||||
# Get changed files
|
||||
changed_files = git_command(['show', '--name-only', '--format=', sha]).split('\n')
|
||||
changed_files = [f for f in changed_files if f.strip()]
|
||||
|
||||
# Analyze file types
|
||||
file_types = set()
|
||||
scopes = set()
|
||||
|
||||
for filepath in changed_files:
|
||||
# Determine file type
|
||||
if 'test' in filepath.lower() or 'spec' in filepath.lower():
|
||||
file_types.add('test')
|
||||
elif filepath.endswith('.md') or 'doc' in filepath.lower():
|
||||
file_types.add('docs')
|
||||
elif any(filepath.endswith(ext) for ext in ['.js', '.ts', '.py', '.go', '.rs', '.java']):
|
||||
file_types.add('code')
|
||||
elif any(filepath.endswith(ext) for ext in ['.json', '.yaml', '.yml', '.toml']):
|
||||
file_types.add('config')
|
||||
|
||||
# Determine scope from path
|
||||
parts = filepath.split('/')
|
||||
if len(parts) > 1:
|
||||
scopes.add(parts[0])
|
||||
|
||||
# Check for multiple types (excluding test + code as acceptable)
|
||||
suspicious_type_mix = False
|
||||
if 'docs' in file_types and 'code' in file_types:
|
||||
suspicious_type_mix = True
|
||||
if 'config' in file_types and len(file_types) > 2:
|
||||
suspicious_type_mix = True
|
||||
|
||||
# Check for multiple scopes
|
||||
multiple_scopes = len(scopes) > 2
|
||||
|
||||
# Size check (too large likely non-atomic)
|
||||
too_large = stats['files_changed'] > 15 or stats['insertions'] > 500
|
||||
|
||||
# Determine atomicity
|
||||
is_atomic = not (suspicious_type_mix or multiple_scopes or too_large)
|
||||
|
||||
issues = []
|
||||
if suspicious_type_mix:
|
||||
issues.append(f"Mixes {' and '.join(file_types)}")
|
||||
if multiple_scopes:
|
||||
issues.append(f"Affects multiple scopes: {', '.join(sorted(scopes))}")
|
||||
if too_large:
|
||||
issues.append(f"Large commit: {stats['files_changed']} files")
|
||||
|
||||
return {
|
||||
'atomic': is_atomic,
|
||||
'file_types': sorted(file_types),
|
||||
'scopes': sorted(scopes),
|
||||
'issues': issues,
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Quality scoring
|
||||
################################################################################
|
||||
|
||||
def calculate_score(message: Dict[str, Any], stats: Dict[str, int], atomicity: Dict[str, Any]) -> Tuple[int, str, List[str]]:
|
||||
"""Calculate overall quality score (0-100)"""
|
||||
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
# Message quality (40 points)
|
||||
if not message['conventional']:
|
||||
score -= 10
|
||||
issues.append("Not using conventional commits format")
|
||||
|
||||
if not message['subject_ok']:
|
||||
score -= 5
|
||||
issues.append(f"Subject too long ({message['subject_length']} chars, should be ≤50)")
|
||||
|
||||
if not message['imperative']:
|
||||
score -= 5
|
||||
issues.append("Subject not in imperative mood")
|
||||
|
||||
if not message['has_body'] and stats['files_changed'] > 3:
|
||||
score -= 10
|
||||
issues.append("No commit body explaining changes")
|
||||
elif message['has_body'] and not message['explanation']:
|
||||
score -= 5
|
||||
issues.append("Commit body too brief")
|
||||
|
||||
# Atomicity (30 points)
|
||||
if not atomicity['atomic']:
|
||||
score -= 20
|
||||
issues.extend(atomicity['issues'])
|
||||
|
||||
# Test coverage (20 points)
|
||||
has_code_changes = 'code' in atomicity['file_types']
|
||||
has_test_changes = stats['test_files'] > 0
|
||||
|
||||
if has_code_changes and not has_test_changes and stats['insertions'] > 100:
|
||||
score -= 15
|
||||
issues.append("No tests included for significant code changes")
|
||||
elif has_code_changes and not has_test_changes:
|
||||
score -= 5
|
||||
issues.append("No tests included")
|
||||
|
||||
# Size appropriateness (10 points)
|
||||
if stats['files_changed'] > 20:
|
||||
score -= 5
|
||||
issues.append(f"Very large commit ({stats['files_changed']} files)")
|
||||
|
||||
if stats['insertions'] > 1000:
|
||||
score -= 5
|
||||
issues.append(f"Very large changeset ({stats['insertions']} insertions)")
|
||||
|
||||
# Determine quality level
|
||||
if score >= 90:
|
||||
quality = "excellent"
|
||||
elif score >= 70:
|
||||
quality = "good"
|
||||
elif score >= 50:
|
||||
quality = "fair"
|
||||
else:
|
||||
quality = "poor"
|
||||
|
||||
return max(0, score), quality, issues
|
||||
|
||||
################################################################################
|
||||
# Main execution
|
||||
################################################################################
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "Usage: commit-reviewer.py <commit-sha>"}))
|
||||
sys.exit(2)
|
||||
|
||||
commit_sha = sys.argv[1]
|
||||
|
||||
# Check if git repository
|
||||
if not git_command(['rev-parse', '--git-dir']):
|
||||
print(json.dumps({"error": "Not a git repository"}))
|
||||
sys.exit(2)
|
||||
|
||||
# Check if commit exists
|
||||
if not commit_exists(commit_sha):
|
||||
print(json.dumps({"error": f"Commit not found: {commit_sha}"}))
|
||||
sys.exit(1)
|
||||
|
||||
# Gather commit information
|
||||
info = get_commit_info(commit_sha)
|
||||
stats = get_commit_stats(commit_sha)
|
||||
message_analysis = analyze_message(info['subject'], info['body'])
|
||||
atomicity_analysis = analyze_atomicity(commit_sha, stats)
|
||||
|
||||
# Calculate quality score
|
||||
score, quality, issues = calculate_score(message_analysis, stats, atomicity_analysis)
|
||||
|
||||
# Build output
|
||||
output = {
|
||||
'commit': info['sha'][:8],
|
||||
'author': info['author'],
|
||||
'date': info['date'],
|
||||
'message': {
|
||||
'subject': message_analysis['subject'],
|
||||
'body': message_analysis['body'],
|
||||
'subject_length': message_analysis['subject_length'],
|
||||
'has_body': message_analysis['has_body'],
|
||||
'conventional': message_analysis['conventional'],
|
||||
'type': message_analysis['type'],
|
||||
'scope': message_analysis['scope'],
|
||||
},
|
||||
'changes': {
|
||||
'files_changed': stats['files_changed'],
|
||||
'insertions': stats['insertions'],
|
||||
'deletions': stats['deletions'],
|
||||
'test_files': stats['test_files'],
|
||||
'doc_files': stats['doc_files'],
|
||||
},
|
||||
'quality': {
|
||||
'atomic': atomicity_analysis['atomic'],
|
||||
'message_quality': quality,
|
||||
'test_coverage': stats['test_files'] > 0,
|
||||
'issues': issues,
|
||||
},
|
||||
'score': score,
|
||||
}
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
322
commands/commit-best-practices/.scripts/pre-commit-check.sh
Executable file
322
commands/commit-best-practices/.scripts/pre-commit-check.sh
Executable file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env bash
|
||||
################################################################################
|
||||
# Pre-Commit Validation Script
|
||||
#
|
||||
# Purpose: Run comprehensive pre-commit checks to ensure code quality
|
||||
# Version: 1.0.0
|
||||
# Usage: ./pre-commit-check.sh [quick:true|false]
|
||||
# Returns: JSON with validation results
|
||||
# Exit Codes:
|
||||
# 0 = All checks passed
|
||||
# 1 = One or more checks failed
|
||||
# 2 = Script execution error
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default to full validation
|
||||
QUICK_MODE="${1:-quick:false}"
|
||||
QUICK_MODE="${QUICK_MODE#quick:}"
|
||||
|
||||
# Initialize results
|
||||
OVERALL_STATUS="pass"
|
||||
declare -A RESULTS
|
||||
|
||||
################################################################################
|
||||
# Check if tests pass
|
||||
################################################################################
|
||||
check_tests() {
|
||||
local status="skip"
|
||||
local message="Tests skipped in quick mode"
|
||||
|
||||
if [[ "$QUICK_MODE" != "true" ]]; then
|
||||
# Detect test framework and run tests
|
||||
if [[ -f "package.json" ]] && grep -q "\"test\":" package.json; then
|
||||
if npm test &>/dev/null; then
|
||||
status="pass"
|
||||
message="All tests passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Tests failing"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
elif [[ -f "pytest.ini" ]] || [[ -f "setup.py" ]]; then
|
||||
if python -m pytest &>/dev/null; then
|
||||
status="pass"
|
||||
message="All tests passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Tests failing"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
elif [[ -f "Cargo.toml" ]]; then
|
||||
if cargo test &>/dev/null; then
|
||||
status="pass"
|
||||
message="All tests passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Tests failing"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
elif [[ -f "go.mod" ]]; then
|
||||
if go test ./... &>/dev/null; then
|
||||
status="pass"
|
||||
message="All tests passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Tests failing"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
else
|
||||
status="skip"
|
||||
message="No test framework detected"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check if lint passes
|
||||
################################################################################
|
||||
check_lint() {
|
||||
local status="skip"
|
||||
local message="Linting skipped in quick mode"
|
||||
|
||||
if [[ "$QUICK_MODE" != "true" ]]; then
|
||||
# Detect linter and run
|
||||
if [[ -f "package.json" ]] && (grep -q "eslint" package.json || [[ -f ".eslintrc.json" ]]); then
|
||||
if npx eslint . --max-warnings 0 &>/dev/null; then
|
||||
status="pass"
|
||||
message="Linting passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Linting errors found"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
elif command -v pylint &>/dev/null; then
|
||||
if pylint $(git diff --cached --name-only --diff-filter=ACM | grep '\.py$') &>/dev/null; then
|
||||
status="pass"
|
||||
message="Linting passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Linting errors found"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
elif command -v clippy &>/dev/null; then
|
||||
if cargo clippy -- -D warnings &>/dev/null; then
|
||||
status="pass"
|
||||
message="Linting passed"
|
||||
else
|
||||
status="fail"
|
||||
message="Linting errors found"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
else
|
||||
status="skip"
|
||||
message="No linter detected"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\"}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check for debug code in staged files
|
||||
################################################################################
|
||||
check_debug_code() {
|
||||
local count=0
|
||||
local locations=()
|
||||
|
||||
# Get staged diff
|
||||
local diff_output
|
||||
diff_output=$(git diff --cached)
|
||||
|
||||
# Search for debug patterns in added lines only
|
||||
local debug_patterns=(
|
||||
'console\.log'
|
||||
'console\.debug'
|
||||
'console\.error'
|
||||
'debugger'
|
||||
'print\('
|
||||
'println!'
|
||||
'pdb\.set_trace'
|
||||
'binding\.pry'
|
||||
'byebug'
|
||||
'Debug\.Log'
|
||||
)
|
||||
|
||||
for pattern in "${debug_patterns[@]}"; do
|
||||
while IFS= read -r line; do
|
||||
if [[ -n "$line" ]]; then
|
||||
((count++))
|
||||
locations+=("\"$line\"")
|
||||
fi
|
||||
done < <(echo "$diff_output" | grep "^+" | grep -v "^+++" | grep -E "$pattern" || true)
|
||||
done
|
||||
|
||||
local status="pass"
|
||||
local message="No debug code found"
|
||||
|
||||
if [[ $count -gt 0 ]]; then
|
||||
status="fail"
|
||||
message="Found $count debug statement(s)"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
|
||||
# Format locations array
|
||||
local locations_json="[]"
|
||||
if [[ ${#locations[@]} -gt 0 ]]; then
|
||||
locations_json="[$(IFS=,; echo "${locations[*]}")]"
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\", \"count\": $count, \"locations\": $locations_json}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check for TODOs in staged files
|
||||
################################################################################
|
||||
check_todos() {
|
||||
local count=0
|
||||
local locations=()
|
||||
|
||||
# Get staged diff
|
||||
local diff_output
|
||||
diff_output=$(git diff --cached)
|
||||
|
||||
# Search for TODO patterns in added lines only
|
||||
local todo_patterns=(
|
||||
'TODO'
|
||||
'FIXME'
|
||||
'XXX'
|
||||
'HACK'
|
||||
)
|
||||
|
||||
for pattern in "${todo_patterns[@]}"; do
|
||||
while IFS= read -r line; do
|
||||
if [[ -n "$line" ]]; then
|
||||
((count++))
|
||||
locations+=("\"$line\"")
|
||||
fi
|
||||
done < <(echo "$diff_output" | grep "^+" | grep -v "^+++" | grep -E "$pattern" || true)
|
||||
done
|
||||
|
||||
local status="pass"
|
||||
local message="No TODOs in staged code"
|
||||
|
||||
if [[ $count -gt 0 ]]; then
|
||||
status="warn"
|
||||
message="Found $count TODO/FIXME comment(s)"
|
||||
# TODOs are warning, not failure (project decision)
|
||||
fi
|
||||
|
||||
# Format locations array
|
||||
local locations_json="[]"
|
||||
if [[ ${#locations[@]} -gt 0 ]]; then
|
||||
locations_json="[$(IFS=,; echo "${locations[*]}")]"
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\", \"count\": $count, \"locations\": $locations_json}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Check for merge conflict markers
|
||||
################################################################################
|
||||
check_merge_markers() {
|
||||
local count=0
|
||||
local locations=()
|
||||
|
||||
# Get staged files
|
||||
local staged_files
|
||||
staged_files=$(git diff --cached --name-only --diff-filter=ACM || true)
|
||||
|
||||
if [[ -n "$staged_files" ]]; then
|
||||
# Search for conflict markers in staged files
|
||||
while IFS= read -r file; do
|
||||
if [[ -f "$file" ]]; then
|
||||
local markers
|
||||
markers=$(grep -n -E '^(<<<<<<<|=======|>>>>>>>)' "$file" || true)
|
||||
if [[ -n "$markers" ]]; then
|
||||
while IFS= read -r marker; do
|
||||
((count++))
|
||||
locations+=("\"$file:$marker\"")
|
||||
done <<< "$markers"
|
||||
fi
|
||||
fi
|
||||
done <<< "$staged_files"
|
||||
fi
|
||||
|
||||
local status="pass"
|
||||
local message="No merge markers found"
|
||||
|
||||
if [[ $count -gt 0 ]]; then
|
||||
status="fail"
|
||||
message="Found $count merge conflict marker(s)"
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
|
||||
# Format locations array
|
||||
local locations_json="[]"
|
||||
if [[ ${#locations[@]} -gt 0 ]]; then
|
||||
locations_json="[$(IFS=,; echo "${locations[*]}")]"
|
||||
fi
|
||||
|
||||
echo "{\"status\": \"$status\", \"message\": \"$message\", \"count\": $count, \"locations\": $locations_json}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main execution
|
||||
################################################################################
|
||||
main() {
|
||||
# Verify we're in a git repository
|
||||
if ! git rev-parse --git-dir &>/dev/null; then
|
||||
echo "{\"error\": \"Not a git repository\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Check if there are staged changes
|
||||
if ! git diff --cached --quiet 2>/dev/null; then
|
||||
: # Has staged changes, continue
|
||||
else
|
||||
echo "{\"error\": \"No staged changes to validate\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Run all checks
|
||||
local tests_result
|
||||
local lint_result
|
||||
local debug_result
|
||||
local todos_result
|
||||
local markers_result
|
||||
|
||||
tests_result=$(check_tests)
|
||||
lint_result=$(check_lint)
|
||||
debug_result=$(check_debug_code)
|
||||
todos_result=$(check_todos)
|
||||
markers_result=$(check_merge_markers)
|
||||
|
||||
# Build JSON output
|
||||
cat <<EOF
|
||||
{
|
||||
"status": "$OVERALL_STATUS",
|
||||
"quick_mode": $([[ "$QUICK_MODE" == "true" ]] && echo "true" || echo "false"),
|
||||
"checks": {
|
||||
"tests": $tests_result,
|
||||
"lint": $lint_result,
|
||||
"debug_code": $debug_result,
|
||||
"todos": $todos_result,
|
||||
"merge_markers": $markers_result
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Exit with appropriate code
|
||||
if [[ "$OVERALL_STATUS" == "fail" ]]; then
|
||||
exit 1
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute main function
|
||||
main "$@"
|
||||
259
commands/commit-best-practices/.scripts/revert-helper.sh
Executable file
259
commands/commit-best-practices/.scripts/revert-helper.sh
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env bash
|
||||
################################################################################
|
||||
# Revert Helper Script
|
||||
#
|
||||
# Purpose: Generate proper revert commit message and analyze safety
|
||||
# Version: 1.0.0
|
||||
# Usage: ./revert-helper.sh <commit-sha>
|
||||
# Returns: JSON with revert information
|
||||
# Exit Codes:
|
||||
# 0 = Success
|
||||
# 1 = Commit not found
|
||||
# 2 = Script execution error
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
################################################################################
|
||||
# Parse conventional commit message
|
||||
################################################################################
|
||||
parse_commit_message() {
|
||||
local subject="$1"
|
||||
|
||||
local type=""
|
||||
local scope=""
|
||||
local description=""
|
||||
|
||||
# Try to match conventional format: type(scope): description
|
||||
if [[ "$subject" =~ ^([a-z]+)(\([a-z0-9\-]+\)):[[:space:]](.+)$ ]]; then
|
||||
type="${BASH_REMATCH[1]}"
|
||||
scope="${BASH_REMATCH[2]}" # includes parentheses
|
||||
scope="${scope#(}" # remove leading (
|
||||
scope="${scope%)}" # remove trailing )
|
||||
description="${BASH_REMATCH[3]}"
|
||||
# Try to match without scope: type: description
|
||||
elif [[ "$subject" =~ ^([a-z]+):[[:space:]](.+)$ ]]; then
|
||||
type="${BASH_REMATCH[1]}"
|
||||
scope=""
|
||||
description="${BASH_REMATCH[2]}"
|
||||
else
|
||||
# Non-conventional format
|
||||
type=""
|
||||
scope=""
|
||||
description="$subject"
|
||||
fi
|
||||
|
||||
echo "$type|$scope|$description"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Generate revert commit message
|
||||
################################################################################
|
||||
generate_revert_message() {
|
||||
local commit_sha="$1"
|
||||
local original_subject="$2"
|
||||
|
||||
# Parse original message
|
||||
local parsed
|
||||
parsed=$(parse_commit_message "$original_subject")
|
||||
|
||||
local type
|
||||
local scope
|
||||
local description
|
||||
|
||||
IFS='|' read -r type scope description <<< "$parsed"
|
||||
|
||||
# Build revert message
|
||||
local revert_subject
|
||||
if [[ -n "$type" ]]; then
|
||||
# Conventional format
|
||||
if [[ -n "$scope" ]]; then
|
||||
revert_subject="revert: $type($scope): $description"
|
||||
else
|
||||
revert_subject="revert: $type: $description"
|
||||
fi
|
||||
else
|
||||
# Non-conventional format
|
||||
revert_subject="revert: $original_subject"
|
||||
fi
|
||||
|
||||
# Build full message (subject + body + footer)
|
||||
local revert_message
|
||||
revert_message=$(cat <<EOF
|
||||
$revert_subject
|
||||
|
||||
This reverts commit $commit_sha.
|
||||
|
||||
Reason: [Provide reason for revert here]
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "$revert_message"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Analyze revert safety
|
||||
################################################################################
|
||||
analyze_revert_safety() {
|
||||
local commit_sha="$1"
|
||||
|
||||
local safe_to_revert="true"
|
||||
local warnings=()
|
||||
|
||||
# Check for dependent commits (commits that touch same files after this one)
|
||||
local files_changed
|
||||
files_changed=$(git show --name-only --format= "$commit_sha")
|
||||
|
||||
local dependent_count=0
|
||||
local dependent_commits=()
|
||||
|
||||
if [[ -n "$files_changed" ]]; then
|
||||
# Get commits after this one
|
||||
local later_commits
|
||||
later_commits=$(git log "$commit_sha"..HEAD --oneline --format='%h %s' || echo "")
|
||||
|
||||
if [[ -n "$later_commits" ]]; then
|
||||
while IFS= read -r commit_line; do
|
||||
local later_sha
|
||||
later_sha=$(echo "$commit_line" | awk '{print $1}')
|
||||
|
||||
# Check if any files overlap
|
||||
local later_files
|
||||
later_files=$(git show --name-only --format= "$later_sha" 2>/dev/null || echo "")
|
||||
|
||||
# Check for file overlap
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" ]] && echo "$files_changed" | grep -qxF "$file"; then
|
||||
dependent_commits+=("$commit_line")
|
||||
((dependent_count++))
|
||||
break
|
||||
fi
|
||||
done <<< "$later_files"
|
||||
done <<< "$later_commits"
|
||||
|
||||
if [[ $dependent_count -gt 0 ]]; then
|
||||
safe_to_revert="false"
|
||||
warnings+=("\"$dependent_count commit(s) depend on this change\"")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if files still exist (if deleted, might be harder to revert)
|
||||
local deleted_files=0
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" ]] && [[ ! -f "$file" ]]; then
|
||||
((deleted_files++))
|
||||
fi
|
||||
done <<< "$files_changed"
|
||||
|
||||
if [[ $deleted_files -gt 0 ]]; then
|
||||
warnings+=("\"$deleted_files file(s) from commit no longer exist\"")
|
||||
fi
|
||||
|
||||
# Check for potential merge conflicts (files modified since commit)
|
||||
local modified_files=0
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" ]] && [[ -f "$file" ]]; then
|
||||
# Check if file has been modified since this commit
|
||||
local file_changed
|
||||
file_changed=$(git log "$commit_sha"..HEAD --oneline -- "$file" | wc -l)
|
||||
if [[ $file_changed -gt 0 ]]; then
|
||||
((modified_files++))
|
||||
fi
|
||||
fi
|
||||
done <<< "$files_changed"
|
||||
|
||||
if [[ $modified_files -gt 0 ]]; then
|
||||
warnings+=("\"$modified_files file(s) modified since commit - potential conflicts\"")
|
||||
fi
|
||||
|
||||
# Format warnings array
|
||||
local warnings_json="[]"
|
||||
if [[ ${#warnings[@]} -gt 0 ]]; then
|
||||
warnings_json="[$(IFS=,; echo "${warnings[*]}")]"
|
||||
fi
|
||||
|
||||
echo "{\"safe_to_revert\": $safe_to_revert, \"warnings\": $warnings_json, \"dependent_count\": $dependent_count}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main execution
|
||||
################################################################################
|
||||
main() {
|
||||
# Check arguments
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "{\"error\": \"Usage: revert-helper.sh <commit-sha>\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local commit_sha="$1"
|
||||
|
||||
# Verify we're in a git repository
|
||||
if ! git rev-parse --git-dir &>/dev/null; then
|
||||
echo "{\"error\": \"Not a git repository\"}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Verify commit exists
|
||||
if ! git rev-parse --verify "$commit_sha" &>/dev/null 2>&1; then
|
||||
echo "{\"error\": \"Commit not found: $commit_sha\"}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get full commit SHA
|
||||
local full_sha
|
||||
full_sha=$(git rev-parse "$commit_sha")
|
||||
|
||||
local short_sha
|
||||
short_sha=$(git rev-parse --short "$commit_sha")
|
||||
|
||||
# Get commit information
|
||||
local original_subject
|
||||
local original_author
|
||||
local commit_date
|
||||
local files_affected
|
||||
|
||||
original_subject=$(git log -1 --format='%s' "$commit_sha")
|
||||
original_author=$(git log -1 --format='%an <%ae>' "$commit_sha")
|
||||
commit_date=$(git log -1 --format='%ad' --date=short "$commit_sha")
|
||||
files_affected=$(git show --name-only --format= "$commit_sha" | wc -l)
|
||||
|
||||
# Parse commit type and scope
|
||||
local parsed
|
||||
parsed=$(parse_commit_message "$original_subject")
|
||||
|
||||
local type
|
||||
local scope
|
||||
local description
|
||||
|
||||
IFS='|' read -r type scope description <<< "$parsed"
|
||||
|
||||
# Generate revert message
|
||||
local revert_message
|
||||
revert_message=$(generate_revert_message "$short_sha" "$original_subject")
|
||||
|
||||
# Analyze safety
|
||||
local safety_analysis
|
||||
safety_analysis=$(analyze_revert_safety "$commit_sha")
|
||||
|
||||
# Build JSON output
|
||||
cat <<EOF
|
||||
{
|
||||
"commit": "$short_sha",
|
||||
"full_sha": "$full_sha",
|
||||
"original_message": "$original_subject",
|
||||
"original_author": "$original_author",
|
||||
"commit_date": "$commit_date",
|
||||
"type": ${type:+\"$type\"},
|
||||
"scope": ${scope:+\"$scope\"},
|
||||
"files_affected": $files_affected,
|
||||
"revert_message": $(echo "$revert_message" | jq -Rs .),
|
||||
"safety": $safety_analysis
|
||||
}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Execute main function
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user