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