Skip to content

Instantly share code, notes, and snippets.

@simbo1905
Created January 7, 2026 11:59
Show Gist options
  • Select an option

  • Save simbo1905/efac2b24c9a9b317278bcdf94a6c776a to your computer and use it in GitHub Desktop.

Select an option

Save simbo1905/efac2b24c9a9b317278bcdf94a6c776a to your computer and use it in GitHub Desktop.
md2gist - Upload markdown to per-branch GitHub gists

md2gist

Upload markdown files to per-branch GitHub gists with automatic registry mapping.

What It Solves

Provides a workflow for storing per-branch analysis, reports, and documentation in GitHub gists automatically organized by org/repo/branch. Each feature branch gets its own gist for test results, analysis notes, or generated reports.

How It Works

  1. Maintains a registry gist mapping org/repo/branch → gist_url
  2. On first run, creates the registry and a branch-specific gist
  3. Subsequent uploads to the same branch update that gist
  4. Non-interactive mode (-q) for CI/CD pipelines

Requirements

  • uv - Python package manager
  • git - for branch/repo detection
  • gh - GitHub CLI for gist operations

Usage

# Interactive (prompts for registry setup)
md2gist report.md

# Non-interactive (auto-setup, saves config to .md2gist)
md2gist report.md -q

License

MIT © 2026 Simon Massey

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# ///
"""
md2gist - Upload markdown files to per-branch GitHub gists.
SPDX-License-Identifier: MIT
Copyright (c) 2026 Simon Massey
Requires: uv, git, gh CLI
Registry Mapping:
Maintains a registry gist that maps org/repo/branch -> gist_url.
Each branch gets its own gist for feature branch reports, notes, analysis.
Usage:
md2gist file.md [-q|--quiet] [--create-gist true|false] [--save-location local|home]
Example:
md2gist analysis.md # interactive, prompts for registry
md2gist analysis.md -q # auto-create registry, save to .md2gist
"""
import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
def run_cmd(cmd: list[str]) -> tuple[str, str, int]:
"""
Execute a shell command.
Returns a tuple of (stdout, stderr, return_code).
"""
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip(), result.stderr.strip(), result.returncode
def get_current_branch() -> str:
"""
Get current git branch name.
Raises RuntimeError if not in a git repository.
"""
stdout, _, code = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"])
if code != 0:
raise RuntimeError("Not in a git repository")
return stdout
def get_repo_info() -> tuple[str, str]:
"""
Get current git repository owner and name.
Returns (owner, repo_name). Raises RuntimeError if not in git repo or cannot parse remote.
"""
# Try to get remote URL - first try 'origin', then any available remote
remote_url, _, code = run_cmd(["git", "config", "--get", "remote.origin.url"])
if code != 0:
# If 'origin' doesn't exist, get the first available remote
remote_url, _, code = run_cmd(["git", "config", "--get-regexp", "remote\\..*\\.url"])
if code != 0:
raise RuntimeError("Cannot get git remote URL")
# Extract first remote URL from output (format: "remote.name.url <url>")
remote_url = remote_url.split('\n')[0].split(None, 1)[1] if remote_url else ""
if not remote_url:
raise RuntimeError("Cannot get git remote URL")
# Parse owner/repo from URL or SSH
# SSH: git@github.com:owner/repo.git
# HTTPS: https://github.com/owner/repo.git
if "@" in remote_url: # SSH format
parts = remote_url.split(":")[-1]
else: # HTTPS format
parts = remote_url.split("/")[-2:]
parts = "/".join(parts)
parts = parts.rstrip(".git")
if "/" not in parts:
raise RuntimeError(f"Cannot parse owner/repo from remote URL: {remote_url}")
owner, repo = parts.split("/")
return owner, repo
def find_config_file() -> Path | None:
"""
Look for .md2gist config file in current dir, then home dir.
Returns Path to config file or None if not found.
"""
local_config = Path(".md2gist")
if local_config.exists():
return local_config
home_config = Path.home() / ".md2gist"
if home_config.exists():
return home_config
return None
def read_registry_gist(registry_url: str) -> dict[str, str]:
"""
Read registry gist content and parse mappings.
Expected format:
# DO NOT EDIT - Auto-generated by md2gist tool
# Format: org/repo/branch -> gist_url
- org/repo/branch -> https://gist.github.com/user/id
Returns dict mapping "org/repo/branch" -> "gist_url"
"""
gist_id = registry_url.split("/")[-1]
stdout, _, code = run_cmd(["gh", "gist", "view", gist_id])
if code != 0:
return {}
mappings = {}
for line in stdout.split("\n"):
line = line.strip()
if line.startswith("-") and "->" in line:
parts = line.lstrip("- ").split(" -> ")
if len(parts) == 2:
key = parts[0].strip()
url = parts[1].strip()
mappings[key] = url
return mappings
def find_gist_for_context(registry_url: str, owner: str, repo: str, branch: str) -> str | None:
"""
Look up gist URL for org/repo/branch in registry.
Returns gist URL or None if not found.
"""
mappings = read_registry_gist(registry_url)
key = f"{owner}/{repo}/{branch}"
return mappings.get(key)
def create_registry_gist() -> str | None:
"""
Create a new empty registry gist.
Returns gist URL or None on error.
"""
description = "md2gist registry - DO NOT EDIT - Auto-generated tool mappings"
content = """# DO NOT EDIT - Auto-generated by md2gist tool
# Format: org/repo/branch -> gist_url
"""
result = subprocess.run(
[
"gh",
"gist",
"create",
"--desc",
description,
"--filename",
"md2gist.md",
"-",
],
input=content,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Error creating registry gist: {result.stderr}", file=sys.stderr)
return None
gist_url = result.stdout.strip().split("\n")[0]
return gist_url if gist_url else None
def prompt_for_registry(create_gist: bool = True, save_location: str = "local", quiet: bool = False) -> str | None:
"""
Prompt user to create registry gist and choose save location.
Args:
create_gist: Whether to create a new registry gist (default True)
save_location: "local" for .md2gist (default) or "home" for ~/.md2gist
quiet: If True, skip prompts and use defaults
Returns registry URL or None if user cancels.
"""
if not create_gist:
print("Cancelled.")
return None
if not quiet:
print("\nNo md2gist registry found.")
response = input("Create new registry gist? (Y/n): ").strip().lower()
if response == "n":
print("Cancelled.")
return None
print("Creating registry gist...")
registry_url = create_registry_gist()
if not registry_url:
print("Failed to create registry gist.", file=sys.stderr)
return None
print(f"Registry created: {registry_url}")
if not quiet:
print()
print("Where should we save the registry URL?")
print(" 1. .md2gist (repo-local, checked into git)")
print(" 2. ~/.md2gist (home directory, global to all repos)")
choice = input("Choose (1 or 2): ").strip()
save_location = "local" if choice == "1" else "home" if choice == "2" else ""
if save_location == "local":
save_path = Path(".md2gist")
elif save_location == "home":
save_path = Path.home() / ".md2gist"
else:
if not quiet:
print("Invalid choice.")
return registry_url
try:
save_path.write_text(registry_url)
if not quiet:
print(f"Saved to {save_path}")
except Exception as e:
print(f"Warning: Could not save to {save_path}: {e}", file=sys.stderr)
return registry_url
def get_gist_from_api(gist_id: str) -> dict[str, Any] | None:
"""
Fetch gist details from GitHub API.
Returns parsed JSON response or None on error.
"""
stdout, _, code = run_cmd(["gh", "api", f"gists/{gist_id}"])
if code != 0:
return None
try:
result: dict[str, Any] = json.loads(stdout)
return result
except json.JSONDecodeError as e:
print(f"Error parsing gist response: {e}", file=sys.stderr)
return None
def create_gist(filename: str, content: str, owner: str, repo: str, branch: str) -> dict[str, Any] | None:
"""
Create a new gist with given filename and content.
Gist is named "owner/repo/branch Analysis files".
Returns gist API response dict or None on error.
"""
description = f"{owner}/{repo}/{branch} Analysis files"
result = subprocess.run(
[
"gh",
"gist",
"create",
"--desc",
description,
"--filename",
filename,
"-",
],
input=content,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Error creating gist: {result.stderr}", file=sys.stderr)
return None
gist_url = result.stdout.strip().split("\n")[0]
if gist_url:
gist_id = gist_url.split("/")[-1]
return get_gist_from_api(gist_id)
return None
def update_registry(registry_url: str, owner: str, repo: str, branch: str, gist_url: str) -> bool:
"""
Add or update a mapping in the registry gist.
Returns True on success, False on error.
"""
registry_id = registry_url.split("/")[-1]
mappings = read_registry_gist(registry_url)
# Get current content
stdout, _, code = run_cmd(["gh", "gist", "view", registry_id])
if code != 0:
return False
# Add new mapping
key = f"{owner}/{repo}/{branch}"
mappings[key] = gist_url
# Build new content
new_content = """# DO NOT EDIT - Auto-generated by md2gist tool
# Format: org/repo/branch -> gist_url
"""
for k, v in sorted(mappings.items()):
new_content += f"- {k} -> {v}\n"
# Update registry gist
payload = json.dumps({"files": {"md2gist.md": {"content": new_content}}})
result = subprocess.run(
["gh", "api", f"gists/{registry_id}", "-X", "PATCH", "--input", "-"],
input=payload,
capture_output=True,
text=True,
)
return result.returncode == 0
def update_gist_file(gist_id: str, filename: str, content: str) -> bool:
"""
Update or add a file in an existing gist using GitHub API.
Uses PATCH /gists/{id} to add or replace file content.
"""
payload = json.dumps({"files": {filename: {"content": content}}})
result = subprocess.run(
["gh", "api", f"gists/{gist_id}", "-X", "PATCH", "--input", "-"],
input=payload,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Error updating gist file: {result.stderr}", file=sys.stderr)
return False
return True
def format_timestamp(iso_string: str) -> str:
"""
Convert ISO 8601 timestamp to readable format "YYYY-MM-DD HH:MM:SS".
Returns original string if parsing fails.
"""
try:
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
return iso_string
def _make_gist_file_anchor(filename: str) -> str:
"""
Generate GitHub gist anchor for a file.
GitHub's anchor format:
- Spaces → hyphens
- Dots (except .md extension) → hyphens
- Underscores → stay as underscores
- Uppercase → lowercase
- .md extension → -md suffix
Format: file-{name-converted}-md
Examples:
"My File.md" → "file-my-file-md"
"RIO_Bulk_Upload_Dependency_Analysis.md" → "file-rio_bulk_upload_dependency_analysis-md"
"Excel_Products_Databasev5.1-10112025.analysis.20251212-173230.md"
→ "file-excel_products_databasev5-1-10112025-analysis-20251212-173230-md"
"""
# Remove .md extension if present
if filename.endswith(".md"):
name_without_ext = filename[:-3]
else:
name_without_ext = filename
# Convert to lowercase, spaces to hyphens, dots to hyphens, keep underscores
anchor = (
name_without_ext.replace(" ", "-")
.replace(".", "-")
.lower()
)
# Add -md suffix (was the extension)
return f"file-{anchor}-md"
def display_gist_info(gist: dict[str, Any], output_format: str = "md") -> None:
"""
Display gist metadata and file listing in tree format.
Output includes gist URL, creation/modification timestamps, and direct links
to each file for easy navigation without scrolling.
Args:
gist: Gist API response dictionary.
output_format: "md" for markdown links (default) or "html" for HTML anchor links.
"""
gist_id = gist.get("id", "unknown")
owner_login: Any = gist.get("owner", {})
if isinstance(owner_login, dict):
username = owner_login.get("login", "unknown")
else:
username = "unknown"
gist_url = f"https://gist.github.com/{username}/{gist_id}"
created = format_timestamp(str(gist.get("created_at", "N/A")))
updated = format_timestamp(str(gist.get("updated_at", "N/A")))
print(f"\n📄 Gist: {gist_url}")
print(f" Created: {created}")
print(f" Modified: {updated}")
print()
files: Any = gist.get("files", {})
if not files:
print(" (empty)")
return
file_list = list(files.keys())
for i, filename in enumerate(file_list):
is_last = i == len(file_list) - 1
prefix = "└── " if is_last else "├── "
anchor = _make_gist_file_anchor(filename)
file_url = f"{gist_url}#{anchor}"
if output_format == "md":
# Markdown link format
print(f" {prefix}[{filename}]({file_url})")
else:
# HTML link format (default)
print(f" {prefix}<a href=\"{file_url}\">{filename}</a>")
def show_help() -> None:
"""Display help message."""
print("Usage: md2gist [OPTIONS] <markdown-file> [<file2> ...]")
print()
print("Upload markdown files to GitHub gists using a registry.")
print()
print("Options:")
print(" -f, --format {html,md} Output format for file links (default: md)")
print(" md: Markdown links (best for terminals)")
print(" html: HTML anchor tags")
print(" -q, --quiet Non-interactive mode (default: prompt user)")
print(" Creates registry if missing, saves to .md2gist")
print(" --create-gist {true|false} Create new registry gist if not found (default: true)")
print(" --save-location {local|home} Save registry URL to .md2gist (local) or")
print(" ~/.md2gist (home) (default: local)")
print(" -h, --help Show this help message")
print()
print("Registry:")
print(" - Looks for ./.md2gist config (repo-local) or ~/.md2gist (home-global)")
print(" - Config file contains URL to a registry gist")
print(" - Registry maps org/repo/branch -> gist_url")
print(" - If no config found, prompts to create registry gist (skip with -q)")
print()
print("Behavior:")
print(" - Uses org/repo/branch to lookup gist in registry")
print(" - If gist not found: creates new gist, updates registry")
print(" - If gist found & file in gist: updates the file")
print(" - If gist found & file not in gist: adds the file")
print(" - Multiple files processed sequentially")
print()
print("Examples:")
print(" md2gist analysis.md")
print(" md2gist -q report.md # non-interactive")
print(" md2gist --format html report.md")
print(" md2gist file1.md file2.md file3.md")
def process_single_file(
md_file_path: Path,
owner: str,
repo: str,
branch: str,
registry_url: str,
gist: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""
Process a single markdown file - create gist or add/update file.
Returns updated gist dict or None on error.
"""
filename = md_file_path.name
content = md_file_path.read_text()
if not gist:
print("✓ Creating new gist...")
gist = create_gist(filename, content, owner, repo, branch)
if not gist:
print("✗ Failed to create gist", file=sys.stderr)
return None
# Update registry with new gist
gist_id: Any = gist["id"]
owner_login: Any = gist.get("owner", {})
if isinstance(owner_login, dict):
username = owner_login.get("login", "unknown")
else:
username = "unknown"
gist_url = f"https://gist.github.com/{username}/{gist_id}"
if not update_registry(registry_url, owner, repo, branch, gist_url):
print("Warning: Could not update registry", file=sys.stderr)
print("✓ Gist created")
else:
files: Any = gist.get("files", {})
if filename in files:
print(f" ✓ Updating '{filename}'...")
gist_id = gist["id"]
if not update_gist_file(str(gist_id), filename, content):
print(f" ✗ Failed to update '{filename}'", file=sys.stderr)
return gist
gist = get_gist_from_api(str(gist_id))
else:
print(f" ✓ Adding '{filename}'...")
gist_id = gist["id"]
if not update_gist_file(str(gist_id), filename, content):
print(f" ✗ Failed to add '{filename}'", file=sys.stderr)
return gist
gist = get_gist_from_api(str(gist_id))
return gist
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
prog="md2gist",
description="Upload markdown files to a GitHub gist registry.",
add_help=False,
)
parser.add_argument(
"files",
nargs="*",
help="Markdown files to upload",
)
parser.add_argument(
"-f",
"--format",
choices=["html", "md"],
default="md",
help="Output format for file links (default: md)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Non-interactive mode: create registry gist and save to .md2gist",
)
parser.add_argument(
"--create-gist",
type=lambda x: x.lower() in ("true", "1", "yes", "y"),
default=True,
help="Whether to create a new registry gist if not found (default: true)",
)
parser.add_argument(
"--save-location",
choices=["local", "home"],
default="local",
help="Where to save registry URL: local (.md2gist) or home (~/.md2gist, default: local)",
)
parser.add_argument(
"-h",
"--help",
action="store_true",
help="Show help message",
)
args = parser.parse_args()
# Handle help
if args.help or not args.files:
show_help()
sys.exit(0 if args.help else 1)
# Validate files
md_files: list[Path] = []
for f in args.files:
md_file_path = Path(f)
if not md_file_path.exists():
print(f"Error: File not found: {md_file_path}", file=sys.stderr)
sys.exit(1)
if md_file_path.suffix != ".md":
print(
f"Error: File must be markdown (.md): {md_file_path}",
file=sys.stderr,
)
sys.exit(1)
md_files.append(md_file_path)
# Sort files for consistent ordering
md_files.sort(key=lambda p: p.name)
# Get git info
try:
branch = get_current_branch()
owner, repo = get_repo_info()
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(f"Repo: {owner}/{repo}")
print(f"Branch: {branch}")
print(f"Files: {len(md_files)}")
for f in md_files:
print(f" - {f.name}")
print()
# Find registry config
config_path = find_config_file()
if config_path:
registry_url = config_path.read_text().strip()
if not args.quiet:
print(f"✓ Found config at {config_path}")
print(f"✓ Using registry: {registry_url}")
else:
registry_url = prompt_for_registry(
create_gist=args.create_gist,
save_location=args.save_location,
quiet=args.quiet
)
if not registry_url:
sys.exit(1)
if not args.quiet:
print(f"✓ Using registry: {registry_url}")
print()
# Find gist for this context in registry
gist_url = find_gist_for_context(registry_url, owner, repo, branch)
if gist_url:
print(f"✓ Found existing gist for {owner}/{repo}/{branch}")
# Get gist details from API
gist_id = gist_url.split("/")[-1]
gist = get_gist_from_api(gist_id)
else:
print(f"✓ No gist found for {owner}/{repo}/{branch}")
gist = None
# Process each file
for md_file in md_files:
gist = process_single_file(md_file, owner, repo, branch, registry_url, gist)
if not gist:
print("✗ Failed to process files", file=sys.stderr)
sys.exit(1)
print()
print(f"✓ All {len(md_files)} files processed")
if gist:
display_gist_info(gist, output_format=args.format)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment