Skip to content

Instantly share code, notes, and snippets.

@thanhleviet
Created October 27, 2024 20:51
Show Gist options
  • Save thanhleviet/108fcf7b792992082f2aac238a4556a5 to your computer and use it in GitHub Desktop.
Save thanhleviet/108fcf7b792992082f2aac238a4556a5 to your computer and use it in GitHub Desktop.
Analysing diff of files between two branches
#!/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