315 lines
11 KiB
Python
315 lines
11 KiB
Python
import os
|
|
import hashlib
|
|
import filecmp
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import difflib
|
|
|
|
|
|
def get_file_hash(filepath):
|
|
"""Calculate MD5 hash of a file."""
|
|
md5_hash = hashlib.md5()
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
md5_hash.update(chunk)
|
|
return md5_hash.hexdigest()
|
|
except Exception:
|
|
return "Error calculating hash"
|
|
|
|
|
|
def format_size(size_bytes):
|
|
"""Format file size nicely."""
|
|
if size_bytes < 1000:
|
|
return f"{size_bytes}B"
|
|
elif size_bytes < 1000000:
|
|
return f"{size_bytes / 1000:.1f}KB"
|
|
else:
|
|
return f"{size_bytes / 1000000:.1f}MB"
|
|
|
|
|
|
def count_lines(filepath):
|
|
"""Count lines in a file safely."""
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
return sum(1 for _ in f)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def calculate_diff_ratio(file1, file2):
|
|
"""Calculate how different two files are using difflib."""
|
|
try:
|
|
with (
|
|
open(file1, "r", errors="replace") as f1,
|
|
open(file2, "r", errors="replace") as f2,
|
|
):
|
|
text1 = f1.readlines()
|
|
text2 = f2.readlines()
|
|
matcher = difflib.SequenceMatcher(None, text1, text2)
|
|
return 1.0 - matcher.ratio() # Higher value means more different
|
|
except Exception:
|
|
return 1.0 # If we can't compare, assume completely different
|
|
|
|
|
|
def get_relative_path(file_path, base_path):
|
|
"""Get relative path from base directory."""
|
|
return str(Path(file_path).relative_to(base_path))
|
|
|
|
|
|
def collect_files_recursive(folder_path):
|
|
"""Recursively collect all files in a folder."""
|
|
folder_path = Path(folder_path)
|
|
file_dict = {}
|
|
|
|
for path in folder_path.rglob("*"):
|
|
if path.is_file():
|
|
rel_path = get_relative_path(path, folder_path)
|
|
file_dict[rel_path] = path
|
|
|
|
return file_dict
|
|
|
|
|
|
def compare_folders_recursive(folder1, folder2, output_file=None):
|
|
"""Compare two folders recursively and generate a detailed report."""
|
|
# Automatically generate output filename if not specified
|
|
if output_file is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_file = f"folder_comparison_{timestamp}.md"
|
|
|
|
# Get folder paths
|
|
folder1_path = Path(folder1)
|
|
folder2_path = Path(folder2)
|
|
|
|
# Get shorthand names for display
|
|
folder1_name = folder1_path.name
|
|
folder2_name = folder2_path.name
|
|
|
|
# Get all files recursively
|
|
print(f"Collecting files from {folder1_name}...")
|
|
files1 = collect_files_recursive(folder1_path)
|
|
print(f"Collecting files from {folder2_name}...")
|
|
files2 = collect_files_recursive(folder2_path)
|
|
|
|
# All unique file paths across both folders
|
|
all_files = sorted(set(files1.keys()) | set(files2.keys()))
|
|
|
|
# Calculate column widths for alignment
|
|
max_filepath_len = max(len(file) for file in all_files) + 2
|
|
hash_col_width = 10 # For hash comparison
|
|
size_col_width = 20 # For size and line count
|
|
diff_col_width = 30 # For difference description
|
|
|
|
# Initialize report
|
|
report = [
|
|
f"# Recursive Folder Comparison Report: {folder1_name} vs {folder2_name}\n",
|
|
f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
|
|
"## File Comparison Table\n",
|
|
f"| {'File Path'.ljust(max_filepath_len)} | {'Status'.ljust(hash_col_width)} | {folder1_name.ljust(size_col_width)} | {folder2_name.ljust(size_col_width)} | {'Difference'.ljust(diff_col_width)} |",
|
|
f"|{'-' * max_filepath_len}|{'-' * hash_col_width}|{'-' * size_col_width}|{'-' * size_col_width}|{'-' * diff_col_width}|",
|
|
]
|
|
|
|
# Track file categories
|
|
identical_files = []
|
|
different_files = []
|
|
only_in_folder1 = []
|
|
only_in_folder2 = []
|
|
|
|
# Store detailed file comparisons for sorting later
|
|
file_comparisons = []
|
|
|
|
# Compare each file
|
|
print(f"Comparing {len(all_files)} unique files...")
|
|
for rel_path in all_files:
|
|
in_folder1 = rel_path in files1
|
|
in_folder2 = rel_path in files2
|
|
|
|
# Default values
|
|
status = "—"
|
|
size1_str = "—"
|
|
size2_str = "—"
|
|
diff_str = "—"
|
|
diff_ratio = 0
|
|
|
|
# Get details for folder1
|
|
if in_folder1:
|
|
file1 = files1[rel_path]
|
|
size1 = file1.stat().st_size
|
|
lines1 = count_lines(file1)
|
|
hash1 = get_file_hash(file1)
|
|
size1_str = f"{format_size(size1)} ({lines1} lines)"
|
|
else:
|
|
hash1 = "—"
|
|
size1 = 0
|
|
lines1 = 0
|
|
|
|
# Get details for folder2
|
|
if in_folder2:
|
|
file2 = files2[rel_path]
|
|
size2 = file2.stat().st_size
|
|
lines2 = count_lines(file2)
|
|
hash2 = get_file_hash(file2)
|
|
size2_str = f"{format_size(size2)} ({lines2} lines)"
|
|
else:
|
|
hash2 = "—"
|
|
size2 = 0
|
|
lines2 = 0
|
|
|
|
# Calculate difference and determine status
|
|
if not in_folder1:
|
|
status = "MISSING"
|
|
diff_str = f"Only in {folder2_name}"
|
|
only_in_folder2.append(rel_path)
|
|
diff_ratio = 1.0 # Completely different (missing)
|
|
elif not in_folder2:
|
|
status = "MISSING"
|
|
diff_str = f"Only in {folder1_name}"
|
|
only_in_folder1.append(rel_path)
|
|
diff_ratio = 1.0 # Completely different (missing)
|
|
else:
|
|
# Both files exist, compare them
|
|
if hash1 == hash2 and hash1 != "Error calculating hash":
|
|
status = "IDENTICAL"
|
|
diff_str = "Identical content"
|
|
identical_files.append(rel_path)
|
|
diff_ratio = 0.0
|
|
else:
|
|
status = "DIFFERENT"
|
|
size_diff = size2 - size1
|
|
lines_diff = lines2 - lines1
|
|
sign = "+" if size_diff > 0 else ""
|
|
sign_lines = "+" if lines_diff > 0 else ""
|
|
|
|
# Calculate how different the files are
|
|
diff_ratio = calculate_diff_ratio(file1, file2)
|
|
different_files.append((rel_path, diff_ratio))
|
|
|
|
# Format difference description
|
|
diff_pct = int(diff_ratio * 100)
|
|
diff_str = f"{sign}{format_size(size_diff)} ({sign_lines}{lines_diff} lines) {diff_pct}% different"
|
|
|
|
# Store comparison for sorting later
|
|
file_comparisons.append(
|
|
{
|
|
"rel_path": rel_path,
|
|
"status": status,
|
|
"size1_str": size1_str,
|
|
"size2_str": size2_str,
|
|
"diff_str": diff_str,
|
|
"diff_ratio": diff_ratio,
|
|
"in_folder1": in_folder1,
|
|
"in_folder2": in_folder2,
|
|
"hash1": hash1[:8] if len(hash1) > 8 else hash1,
|
|
"hash2": hash2[:8] if len(hash2) > 8 else hash2,
|
|
}
|
|
)
|
|
|
|
# Sort files by difference ratio (most different first)
|
|
file_comparisons.sort(key=lambda x: x["diff_ratio"], reverse=True)
|
|
|
|
# Generate the sorted table
|
|
for comp in file_comparisons:
|
|
hash_display = (
|
|
f"{comp['hash1']}:{comp['hash2']}"
|
|
if comp["status"] == "DIFFERENT"
|
|
else comp["status"]
|
|
)
|
|
report.append(
|
|
f"| {comp['rel_path'].ljust(max_filepath_len)} | "
|
|
f"{hash_display.ljust(hash_col_width)} | "
|
|
f"{comp['size1_str'].ljust(size_col_width)} | "
|
|
f"{comp['size2_str'].ljust(size_col_width)} | "
|
|
f"{comp['diff_str'].ljust(diff_col_width)} |"
|
|
)
|
|
|
|
# Sort different files by difference ratio for the detailed section
|
|
different_files.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
# Add Files Missing From Each Folder sections
|
|
report.append("\n## Files Present in One Folder But Missing in the Other\n")
|
|
|
|
if only_in_folder1:
|
|
report.append(
|
|
f"\n### Files Only in {folder1_name} ({len(only_in_folder1)} files)"
|
|
)
|
|
for rel_path in sorted(only_in_folder1):
|
|
file1 = files1[rel_path]
|
|
size1 = file1.stat().st_size
|
|
lines1 = count_lines(file1)
|
|
hash1 = get_file_hash(file1)[:8]
|
|
report.append(
|
|
f"- `{rel_path}` ({format_size(size1)}, {lines1} lines, hash: {hash1})"
|
|
)
|
|
|
|
if only_in_folder2:
|
|
report.append(
|
|
f"\n### Files Only in {folder2_name} ({len(only_in_folder2)} files)"
|
|
)
|
|
for rel_path in sorted(only_in_folder2):
|
|
file2 = files2[rel_path]
|
|
size2 = file2.stat().st_size
|
|
lines2 = count_lines(file2)
|
|
hash2 = get_file_hash(file2)[:8]
|
|
report.append(
|
|
f"- `{rel_path}` ({format_size(size2)}, {lines2} lines, hash: {hash2})"
|
|
)
|
|
|
|
# Add detailed section for different files
|
|
if different_files:
|
|
report.append(f"\n## Files with Differences (Sorted by Most Different First)\n")
|
|
for rel_path, diff_ratio in different_files:
|
|
file1 = files1[rel_path]
|
|
file2 = files2[rel_path]
|
|
size1 = file1.stat().st_size
|
|
size2 = file2.stat().st_size
|
|
lines1 = count_lines(file1)
|
|
lines2 = count_lines(file2)
|
|
hash1 = get_file_hash(file1)[:8]
|
|
hash2 = get_file_hash(file2)[:8]
|
|
size_diff = size2 - size1
|
|
lines_diff = lines2 - lines1
|
|
diff_pct = int(diff_ratio * 100)
|
|
|
|
report.append(f"### `{rel_path}` ({diff_pct}% different)")
|
|
report.append(
|
|
f"- {folder1_name}: {format_size(size1)}, {lines1} lines, hash: {hash1}"
|
|
)
|
|
report.append(
|
|
f"- {folder2_name}: {format_size(size2)}, {lines2} lines, hash: {hash2}"
|
|
)
|
|
report.append(
|
|
f"- Difference: {size_diff:+d} bytes, {lines_diff:+d} lines\n"
|
|
)
|
|
|
|
# Add summary section
|
|
report.extend(
|
|
[
|
|
"\n## Summary",
|
|
f"- **Total Unique Files**: {len(all_files)}",
|
|
f"- **Files in {folder1_name}**: {len(files1)}",
|
|
f"- **Files in {folder2_name}**: {len(files2)}",
|
|
f"- **Identical Files**: {len(identical_files)}",
|
|
f"- **Different Files**: {len(different_files)}",
|
|
f"- **Files Only in {folder1_name}**: {len(only_in_folder1)}",
|
|
f"- **Files Only in {folder2_name}**: {len(only_in_folder2)}",
|
|
f"\n## Path Information",
|
|
f"- {folder1_name}: `{folder1}`",
|
|
f"- {folder2_name}: `{folder2}`",
|
|
]
|
|
)
|
|
|
|
# Write report to file
|
|
with open(output_file, "w") as f:
|
|
f.write("\n".join(report))
|
|
|
|
print(f"Recursive comparison report written to {output_file}")
|
|
return "\n".join(report)
|
|
|
|
|
|
# Execute the comparison
|
|
if __name__ == "__main__":
|
|
folder1 = "/Users/michaelchung/storyteller-rust/frontend/apps/editor2d/src/Classes/"
|
|
folder2 = "/Users/michaelchung/storyteller-rust/frontend/apps/editor3d/app/Classes/"
|
|
|
|
report = compare_folders_recursive(folder1, folder2)
|