cd /news/developer-tools/create-a-side-by-side-html-word-diff… · home › topics › developer-tools › article
[ARTICLE · art-148128] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

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.

by read6 min views1 publishedOct 9, 2026

| | #!/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()) |
── more in #developer-tools 4 stories · sorted by recency
── more on @git 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/create-a-side-by-sid…] indexed:0 read:6min 2026-10-09 · —