Created
October 27, 2024 20:51
-
-
Save thanhleviet/108fcf7b792992082f2aac238a4556a5 to your computer and use it in GitHub Desktop.
Analysing diff of files between two branches
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Git Analysis Report Generator | |
This script generates an HTML report analysing commits in a Git repository. | |
Thanh Le Viet - TGHI | |
""" | |
import subprocess | |
from datetime import datetime | |
import os | |
from html import escape | |
import re | |
import argparse | |
def convert_ansi_to_html(ansi_text): | |
"""Convert ANSI color codes to HTML/CSS spans""" | |
if not ansi_text: | |
return "" | |
# ANSI color code to CSS color mapping | |
color_map = { | |
'31': 'red', | |
'32': 'green', | |
'33': 'yellow', | |
'34': 'blue', | |
'35': 'magenta', | |
'36': 'cyan', | |
'37': 'white', | |
'91': '#ff5555', # bright red | |
'92': '#55ff55', # bright green | |
'93': '#ffff55', # bright yellow | |
'94': '#5555ff', # bright blue | |
'95': '#ff55ff', # bright magenta | |
'96': '#55ffff', # bright cyan | |
'97': '#ffffff', # bright white | |
} | |
# Replace ANSI reset code | |
text = ansi_text.replace('\x1b[0m', '</span>') | |
# Replace color codes with spans | |
for code, color in color_map.items(): | |
text = text.replace(f'\x1b[{code}m', f'<span style="color: {color}">') | |
# Handle bold/bright variants | |
text = text.replace(f'\x1b[1;{code}m', f'<span style="color: {color}; font-weight: bold">') | |
# Clean up any remaining ANSI codes | |
text = re.sub(r'\x1b\[[0-9;]*[mGKH]', '', text) | |
# Balance any unclosed spans | |
open_spans = text.count('<span') - text.count('</span>') | |
if open_spans > 0: | |
text += '</span>' * open_spans | |
return text | |
def run_git_command(command, color=True): | |
"""Run a git command and return its output""" | |
try: | |
# Split command if it's a string | |
if isinstance(command, str): | |
command = command.split() | |
# Set up environment for color output | |
env = dict(os.environ) | |
if color: | |
env.update({ | |
'TERM': 'xterm-256color', | |
'FORCE_COLOR': '1', # Add this line | |
'GIT_CONFIG_COUNT': '3', # Increase count to 3 | |
'GIT_CONFIG_KEY_0': 'color.ui', | |
'GIT_CONFIG_VALUE_0': 'always', | |
'GIT_CONFIG_KEY_1': 'color.diff', | |
'GIT_CONFIG_VALUE_1': 'always', | |
'GIT_CONFIG_KEY_2': 'color.status', # Add status coloring | |
'GIT_CONFIG_VALUE_2': 'always' | |
}) | |
# Add -c options to force colors | |
if command[0] == 'git': | |
command[1:1] = ['-c', 'color.ui=always', '-c', 'color.diff=always'] | |
result = subprocess.run( | |
command, | |
capture_output=True, | |
text=True, | |
env=env, | |
check=False | |
) | |
if result.returncode != 0: | |
print(f"Warning: Command '{' '.join(command)}' returned status {result.returncode}") | |
print(f"Error output: {result.stderr}") | |
return "" | |
return result.stdout.strip() | |
except Exception as e: | |
print(f"Error running command '{' '.join(command)}': {e}") | |
return "" | |
def get_commit_details(commit_hash): | |
"""Get commit author and date details""" | |
details = {} | |
# Get commit details using format string | |
format_str = { | |
'date': '%cd', | |
'author': '%an', | |
'email': '%ae', | |
'subject': '%s' | |
} | |
for key, fmt in format_str.items(): | |
result = run_git_command([ | |
'git', 'show', '-s', | |
f'--format={fmt}', | |
'--date=format:%Y-%m-%d %H:%M:%S', | |
commit_hash | |
], color=False) | |
details[key] = result | |
# Combine author and email | |
details['author_with_email'] = f"{details['author']} <{details['email']}>" | |
return details | |
def generate_html_header(): | |
"""Generate HTML header with styling""" | |
return """ | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Git Analysis Report</title> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
margin: 20px; | |
background-color: #f8f9fa; | |
} | |
.summary { | |
background-color: #e3f2fd; | |
padding: 20px; | |
border-radius: 8px; | |
margin-bottom: 30px; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
} | |
.summary h2 { | |
margin-top: 0; | |
color: #1976d2; | |
} | |
.commit { | |
margin-bottom: 20px; | |
background-color: white; | |
padding: 20px; | |
border-radius: 8px; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
} | |
.commit-header { | |
background-color: #f1f8ff; | |
padding: 15px; | |
border-radius: 5px; | |
margin-bottom: 15px; | |
} | |
.commit-details { | |
color: #586069; | |
font-size: 0.9em; | |
margin: 10px 0; | |
} | |
.commit-hash { | |
font-family: monospace; | |
background-color: #f6f8fa; | |
padding: 2px 5px; | |
border-radius: 3px; | |
} | |
.commit-subject { | |
font-weight: bold; | |
color: #24292e; | |
margin: 5px 0; | |
} | |
.separator { | |
border-top: 1px solid #ccc; | |
margin: 10px 0; | |
} | |
.file-name { | |
font-weight: bold; | |
color: #2c3e50; | |
} | |
pre { | |
background-color: #1e1e1e; | |
color: #d4d4d4; | |
padding: 15px; | |
border-radius: 5px; | |
overflow-x: auto; | |
font-family: 'Consolas', 'Monaco', monospace; | |
} | |
.diff { | |
white-space: pre-wrap; | |
font-family: 'Consolas', 'Monaco', monospace; | |
line-height: 1.4; | |
} | |
.stats { | |
margin: 10px 0; | |
padding: 10px; | |
background-color: #f8f9fa; | |
border-radius: 5px; | |
} | |
h1 { | |
color: #2c3e50; | |
border-bottom: 2px solid #3498db; | |
padding-bottom: 10px; | |
} | |
h2 { | |
color: #34495e; | |
font-size: 1.5em; | |
margin: 0; | |
} | |
h3 { | |
color: #7f8c8d; | |
font-size: 1.2em; | |
} | |
.error { | |
color: #e74c3c; | |
background-color: #fadbd8; | |
padding: 10px; | |
border-radius: 5px; | |
margin: 10px 0; | |
} | |
.collapsible { | |
background-color: #777; | |
color: white; | |
cursor: pointer; | |
padding: 18px; | |
width: 100%; | |
border: none; | |
text-align: left; | |
outline: none; | |
font-size: 15px; | |
} | |
.active, .collapsible:hover { | |
background-color: #555; | |
} | |
.content { | |
padding: 0 18px; | |
display: none; | |
overflow: hidden; | |
background-color: #f1f1f1; | |
} | |
/* New style for expanded content */ | |
.content.expanded { | |
display: block; | |
} | |
.commit-links { | |
width: 100%; | |
border-collapse: collapse; | |
margin-bottom: 20px; | |
} | |
.commit-links th, .commit-links td { | |
border: 1px solid #ddd; | |
padding: 8px; | |
text-align: left; | |
} | |
.commit-links th { | |
background-color: #f2f2f2; | |
font-weight: bold; | |
} | |
.commit-links tr:nth-child(even) { | |
background-color: #f9f9f9; | |
} | |
.commit-links tr:hover { | |
background-color: #f5f5f5; | |
} | |
.markdown-style { | |
background-color: #f6f8fa; | |
border-radius: 3px; | |
font-family: monospace; | |
padding: 2px 4px; | |
font-size: 0.9em; | |
color: #24292e; | |
} | |
</style> | |
<script> | |
document.addEventListener('DOMContentLoaded', (event) => { | |
var coll = document.getElementsByClassName("collapsible"); | |
var i; | |
for (i = 0; i < coll.length; i++) { | |
coll[i].addEventListener("click", function() { | |
this.classList.toggle("active"); | |
var content = this.nextElementSibling; | |
if (content.style.display === "block") { | |
content.style.display = "none"; | |
} else { | |
content.style.display = "block"; | |
} | |
}); | |
// Expand the first section (Commit History Analysis) by default | |
if (i === 0) { | |
coll[i].classList.add("active"); | |
coll[i].nextElementSibling.classList.add("expanded"); | |
} | |
} | |
}); | |
</script> | |
</head> | |
<body> | |
<h1>Git Analysis Report</h1> | |
""" | |
def generate_html_footer(): | |
"""Generate HTML footer""" | |
return """ | |
</body> | |
</html> | |
""" | |
def analyse_git_history(start_date, end_date, output_file, target_path, source_branch, dest_branch, compare_head): | |
"""Analyse git history and generate HTML report""" | |
# Get all commits in date range in chronological order (--reverse) | |
commits = run_git_command([ | |
'git', 'log', | |
'--reverse', # Show commits from earlier to later | |
f'--since={start_date}', | |
f'--until={end_date}', | |
source_branch, | |
'--format=%H' | |
], color=False).split('\n') | |
# Create HTML file | |
with open(output_file, 'w', encoding='utf-8') as f: | |
f.write(generate_html_header()) | |
# Add collapsible section for commit history | |
f.write('<div class="collapsible-section">\n') | |
f.write('<h2 class="collapsible">Commit History Analysis</h2>\n') | |
f.write('<div class="content expanded">\n') # Add 'expanded' class here | |
f.write('<h3>Written by Thanh Le Viet - TGHI</h3>\n') | |
f.write(f'<p>The analysis scanned all updated files in each commit from <b>{start_date}</b> to <b>{end_date}</b>, it then compared the file if any difference between <b>{source_branch}/HEAD</b> and <b>{dest_branch}/HEAD</b></p>\n') | |
# Update total commits line with more information | |
f.write(f'<p>Total commits: {len([c for c in commits if c])} (from <b>{start_date}</b> to <b>{end_date}</b>)</p>\n') | |
# Add shortcut link to Total files with differences | |
f.write('<p><a href="#total-files-diff">Jump to Total Files with Differences</a></p>\n') | |
# Add shortcut links to each commit with author and date | |
f.write('<h3>Commit Quick Links:</h3>\n') | |
f.write('<table class="commit-links">\n') | |
f.write('<tr><th>Commit</th><th>Author</th><th>Date</th><th>Message</th></tr>\n') # Added Message column | |
for i, commit in enumerate(commits, 1): | |
if commit: | |
details = get_commit_details(commit) | |
short_hash = commit[:7] | |
f.write('<tr>\n') | |
f.write(f'<td><a href="#commit-{short_hash}">Commit {i}: {short_hash}</a></td>\n') | |
f.write(f'<td>{escape(details["author"])}</td>\n') | |
f.write(f'<td>{escape(details["date"])}</td>\n') | |
f.write(f'<td>{escape(details["subject"])}</td>\n') # Added commit message | |
f.write('</tr>\n') | |
f.write('</table>\n') | |
# Process each commit | |
all_files_with_diff = set() # Set to store unique files with differences | |
for i, commit in enumerate(commits, 1): | |
if not commit: | |
continue | |
# Get commit details | |
details = get_commit_details(commit) | |
short_hash = commit[:7] | |
f.write(f'<div id="commit-{short_hash}" class="commit">\n') | |
f.write('<div class="commit-header">\n') | |
f.write(f'<h2>Commit {i}: <span class="commit-hash">{escape(commit)}</span></h2>\n') | |
f.write(f'<div class="commit-subject">{escape(details["subject"])}</div>\n') | |
f.write('<div class="commit-details">\n') | |
f.write(f'Author: {escape(details["author_with_email"])}<br>\n') | |
f.write(f'Date: {escape(details["date"])}\n') | |
f.write('</div>\n') | |
f.write('</div>\n') | |
# Show files changed in this commit | |
f.write('<div class="stats">\n') | |
f.write('<h3>Files changed in this commit:</h3>\n') | |
stats = run_git_command([ | |
'git', 'show', | |
'--stat', | |
'--oneline', | |
commit, | |
'--', | |
'src' | |
], color=True) | |
if stats: | |
f.write(f'<pre>{convert_ansi_to_html(stats)}</pre>\n') | |
else: | |
f.write(f'<div class="error">No changes found in {target_path} </div>\n') | |
f.write('</div>\n') | |
# Get changed files | |
changed_files = run_git_command([ | |
'git', 'show', | |
'--name-only', | |
commit, | |
'--', | |
target_path | |
], color=False).split('\n') | |
# Compare each changed file between branches | |
files_with_diff = [] # List to store files with differences in this commit | |
if any(file and '/' in file for file in changed_files): | |
f.write(f'<h3>Differences (HEAD <-> HEAD) between {dest_branch} and {source_branch} for these files:</h3>\n') | |
for file in changed_files: | |
if file and '/' in file: | |
f.write('<div class="file-diff">\n') | |
f.write(f'<p class="file-name">File: {escape(file)}</p>\n') | |
f.write(f'<p>Command: <code class="markdown-style">git diff {source_branch} {dest_branch} "{file}"</code></p>\n') | |
diff = run_git_command([ | |
'git', 'diff', | |
source_branch, | |
dest_branch, | |
file | |
], color=True) | |
if diff: | |
print(diff) | |
f.write(f'<pre class="diff">{convert_ansi_to_html(diff)}</pre>\n') | |
files_with_diff.append(file) | |
all_files_with_diff.add(file) | |
else: | |
f.write('<div class="error">No differences found</div>\n') | |
f.write('</div>\n') | |
# Write summary for this commit | |
f.write(f'<p>Files with differences in this commit: {len(files_with_diff)}</p>\n') | |
if files_with_diff: | |
f.write('<ul>\n') | |
for file in files_with_diff: | |
f.write(f'<li>{escape(file)}</li>\n') | |
f.write('</ul>\n') | |
f.write('<div class="separator"></div>\n') | |
f.write('</div>\n') | |
# Write total summary at the end of Commit History Analysis | |
f.write('<div id="total-files-diff" class="summary">\n') | |
f.write(f'<h3>Total files with differences: {len(all_files_with_diff)}</h3>\n') | |
if all_files_with_diff: | |
f.write('<ul>\n') | |
for file in sorted(all_files_with_diff): | |
f.write(f'<li>{escape(file)}</li>\n') | |
f.write('</ul>\n') | |
f.write('</div>\n') | |
f.write('</div>\n') # Close content div | |
f.write('</div>\n') # Close collapsible-section div | |
# Add collapsible section for branch comparison only if compare_head is True | |
if compare_head: | |
f.write('<div class="collapsible-section">\n') | |
f.write(f'<h2 class="collapsible">Branch Comparison: {source_branch} vs {dest_branch} (HEAD to HEAD)</h2>\n') | |
f.write('<div class="content">\n') | |
# Add summary section at the top of branch comparison | |
f.write('<div class="summary">\n') | |
f.write(f'<h3>Summary</h3>\n') | |
f.write(f'<p>Time period: {start_date} to {end_date}</p>\n') | |
f.write('</div>\n') | |
# Add diff summary between source_branch and dest_branch | |
diff_summary = run_git_command([ | |
'git', 'diff', '--name-only', source_branch, dest_branch, '--', target_path | |
], color=False).split('\n') | |
diff_files = [file for file in diff_summary if file.strip()] | |
f.write(f'<p>Files different between {source_branch} (HEAD) and {dest_branch} (HEAD): {len(diff_files)}</p>\n') | |
if diff_files: | |
f.write('<ul>\n') | |
for file in diff_files: | |
f.write(f'<li>{escape(file)}</li>\n') | |
f.write('</ul>\n') | |
# Add diff for each file | |
f.write(f'<h3>Differences (HEAD <-> HEAD) between {source_branch} and {dest_branch} for each file:</h3>\n') | |
for file in diff_files: | |
f.write('<div class="file-diff">\n') | |
f.write(f'<p class="file-name">File: {escape(file)}</p>\n') | |
f.write(f'<p class="branch-info">Showing differences: {source_branch} → {dest_branch}</p>\n') | |
diff = run_git_command([ | |
'git', 'diff', source_branch, dest_branch, '--', file | |
], color=True) | |
if diff: | |
f.write(f'<pre class="diff">{convert_ansi_to_html(diff)}</pre>\n') | |
else: | |
f.write('<div class="error">No differences found between {source_branch} and {dest_branch}</div>\n') | |
f.write('</div>\n') | |
f.write('</div>\n') # Close content div | |
f.write('</div>\n') # Close collapsible-section div | |
f.write(generate_html_footer()) | |
def main(): | |
parser = argparse.ArgumentParser(description="Generate Git analysis report") | |
parser.add_argument("--start-date", default="2024-07-01", help="Start date for analysis (YYYY-MM-DD)") | |
parser.add_argument("--end-date", default="2024-10-25", help="End date for analysis (YYYY-MM-DD)") | |
parser.add_argument("--output", default="git_analysis_report.html", help="Output file name") | |
parser.add_argument("--path", default="src", help="Target path for analysis") | |
parser.add_argument("--source", default="trunk", help="Source branch for comparison") | |
parser.add_argument("--dest", default="tghi-dev", help="Destination branch for comparison") | |
parser.add_argument("--compare-head", action="store_true", help="Enable head-to-head branch comparison") | |
args = parser.parse_args() | |
# Run analysis | |
analyse_git_history(args.start_date, args.end_date, args.output, args.path, args.source, args.dest, args.compare_head) | |
print(f"Report generated: {args.output}") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment