| | #!/usr/bin/env -S uv run --script | | | """Create a side-by-side HTML word diff between two Git revisions.""" | | | import argparse | | | from html import escape | | | from pathlib import Path | | | import re | | | import subprocess | | | import sys |
| | MARKER = re.compile(r"(\[-.*?-\]\|\{\+.*?\+\})") |
| | def parse_paths(value: str \| None) -> list[str]: |
| | """Parse a comma-separated list of paths for the Git pathspec.""" | | | if value is None: |
| | return [] |
| | paths = [path.strip() for path in value.split(",")] |
| | if any(not path for path in paths): |
| | raise argparse.ArgumentTypeError("file lists must not contain empty entries") | | | return paths | | | def render_side(line: str, side: str) -> tuple[str, str]: | | | """Render one diff line for the original or new side.""" |
| | rendered: list[str] = [] |
| | has_deletion = has_addition = has_context = False |
| | for part in MARKER.split(line): |
| | if not part: | | | continue | | | if part.startswith("[-") and part.endswith("-]"): | | | has_deletion = True |
| | if side == "old": |
| | rendered.append(f'<span class="del">{escape(part[2:-2])}</span>') |
| | elif part.startswith("{+") and part.endswith("+}"): |
| | has_addition = True |
| | if side == "new": |
| | rendered.append(f'<span class="add">{escape(part[2:-2])}</span>') |
| | else: |
| | has_context \|= bool(part.strip()) |
| | rendered.append(escape(part)) |
| | cell_class = "" | | | if side == "old" and has_deletion and not has_addition and not has_context: | | | cell_class = ' class="whole-del"' | | | elif side == "new" and has_addition and not has_deletion and not has_context: | | | cell_class = ' class="whole-add"' | | | return "".join(rendered) or "", cell_class |
| | def diff_to_html(diff: str, old: str, new: str) -> str: |
| | rows: list[str] = [] |
| | for line in diff.splitlines(): |
| | if line.startswith("diff --git "): |
| | heading = escape(line[len("diff --git ") :]) |
| | rows.append(f'<tr class="file"><th colspan="2">{heading}</th></tr>') |
| | elif line.startswith("@@ "): |
| | rows.append(f'<tr class="hunk"><th colspan="2">{escape(line)}</th></tr>') |
| | elif line.startswith(("index ", "--- ", "+++ ")): |
| | rows.append(f'<tr class="meta"><td colspan="2">{escape(line)}</td></tr>') |
| | else: |
| | original, original_class = render_side(line, "old") |
| | updated, updated_class = render_side(line, "new") |
| | rows.append( |
| | f"<tr><td{original_class}><pre>{original}</pre></td>" |
| | f"<td{updated_class}><pre>{updated}</pre></td></tr>" |
| | ) | | | return f'''<!doctype html> | | | <html lang="en"> | | | <head> |
| | <meta charset="utf-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | <title>Diff: {escape(old)} → {escape(new)}</title> |
| | <style> |
| | :root {{ color-scheme: dark; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }} |
| | * {{ box-sizing: border-box; }} |
| | body {{ margin: 0; background: #111318; color: #e5e7eb; }} |
| | header {{ position: sticky; top: 0; z-index: 2; display: grid; grid-template-columns: 1fr 1fr; background: #20232b; border-bottom: 1px solid #3b404b; }} |
| | header div {{ padding: .8rem 1rem; font-weight: 700; }} |
| | header div + div {{ border-left: 1px solid #3b404b; }} |
| | .wrap {{ overflow: auto; }} |
| | table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }} |
| | td {{ width: 50%; vertical-align: top; padding: 0; border-bottom: 1px solid #292d35; }} |
| | td + td {{ border-left: 1px solid #3b404b; }} |
| | pre {{ margin: 0; padding: .16rem .75rem; min-height: 1.55em; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; font: inherit; line-height: 1.4; }} |
| | .file th, .hunk th {{ text-align: left; padding: .55rem .8rem; }} |
| | .file th {{ color: #f3f4f6; background: #303540; border-top: 1px solid #525866; }} |
| | .hunk th {{ color: #aab7d0; background: #1d2634; font-weight: 500; }} |
| | .meta td {{ padding: .2rem .8rem; color: #8b93a2; background: #171a20; font-size: .9em; }} |
| | .del {{ color: #ffe3e3; background: #762626; }} |
| | .add {{ color: #e0ffe6; background: #14532d; }} |
| | .whole-del {{ background: #4b171b; }} |
| | .whole-add {{ background: #123c25; }} |
| | @media (max-width: 700px) {{ pre {{ padding-inline: .4rem; font-size: 12px; }} }} |
| | </style> | | | </head> | | | <body> |
| | <header><div>Original: {escape(old)}</div><div>New: {escape(new)}</div></header> |
| | <div class="wrap"><table aria-label="Side-by-side diff"><tbody> |
| | {chr(10).join(rows)} |
| | </tbody></table></div> |
| | </body> | | | </html> | | | ''' | | | def safe_name(revision: str) -> str: | | | """Make a revision label suitable for a default output filename.""" | | | name = re.sub(r"[^A-Za-z0-9._-]+", "-", revision).strip(".-") | | | return name or "revision" | | | def main() -> int: | | | parser = argparse.ArgumentParser( | | | description="Create a side-by-side HTML word diff between two Git revisions." | | | ) | | | parser.add_argument("oldbranch", help="original branch, tag, or commit") | | | parser.add_argument("newbranch", help="new branch, tag, or commit") | | | parser.add_argument( | | | "--only", | | | type=parse_paths, | | | metavar="FILE1,FILE2,...", | | | help="include only these comma-separated paths", | | | ) | | | parser.add_argument( | | | "--exclude", | | | type=parse_paths, | | | metavar="FILE1,FILE2,...", | | | help="exclude these comma-separated paths", | | | ) | | | parser.add_argument( | | | "-o", | | | "--output", | | | type=Path, | | | help="output HTML path (default: OLD-to-NEW.html)", | | | ) |
| | args = parser.parse_args() |
| | pathspecs: list[str] = [] |
| | if args.only: | | | pathspecs.extend(f":(top){path}" for path in args.only) | | | if args.exclude: |
| | pathspecs.extend(f":(top,exclude){path}" for path in args.exclude) |
| | command = [ |
| | "git", | | | "diff", |
| | "--ignore-all-space", |
| | "--word-diff=plain", |
| | args.oldbranch, | | | args.newbranch, | | | "--", | | | *pathspecs, | | | ] | | | try: | | | result = subprocess.run(command, check=True, capture_output=True, text=True) | | | except FileNotFoundError: | | | print("deezer: git was not found; install Git and try again", file=sys.stderr) | | | return 127 | | | except subprocess.CalledProcessError as error: | | | if error.stderr: | | | print(error.stderr, end="", file=sys.stderr) | | | return error.returncode |
| | output = args.output or Path( |
| | f"{safe_name(args.oldbranch)}-to-{safe_name(args.newbranch)}.html" |
| | ) | | | try: | | | output.write_text( | | | diff_to_html(result.stdout, args.oldbranch, args.newbranch), | | | encoding="utf-8", | | | ) | | | except OSError as error: | | | print(f"deezer: cannot write {output}: {error}", file=sys.stderr) | | | return 1 | | | print(f"Wrote {output}") | | | return 0 |
| | if __name__ == "__main__": |
| | raise SystemExit(main()) |