# Create a side-by-side HTML word diff between two Git revisions (generated with gpt-6-luna)

> Source: <https://gist.github.com/mseri/e368e6ffaf5dcb2b66876da396068df4>
> Published: 2026-10-09 08:11:33+00:00

|  | #!/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()) |
