|
#!/usr/bin/env python3 |
|
# /// script |
|
# requires-python = ">=3.9" |
|
# dependencies = [] |
|
# /// |
|
|
|
import argparse |
|
import os |
|
import shutil |
|
import subprocess |
|
import sys |
|
import tempfile |
|
from pathlib import Path |
|
from typing import Optional |
|
from urllib.parse import urlparse |
|
|
|
|
|
def which_or_die(cmd: str) -> str: |
|
path = shutil.which(cmd) |
|
if not path: |
|
print(f"Error: required command '{cmd}' is not installed or not in PATH.", file=sys.stderr) |
|
sys.exit(2) |
|
return path |
|
|
|
|
|
def run(cmd, *, cwd: Optional[Path] = None, input_text: Optional[str] = None) -> str: |
|
try: |
|
res = subprocess.run( |
|
cmd, |
|
cwd=str(cwd) if cwd else None, |
|
input=input_text.encode() if input_text is not None else None, |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.PIPE, |
|
check=True, |
|
) |
|
return res.stdout.decode().strip() |
|
except subprocess.CalledProcessError as e: |
|
out = e.stdout.decode(errors="ignore").strip() |
|
err = e.stderr.decode(errors="ignore").strip() |
|
msg = f"Command failed: {' '.join(cmd)}\nSTDOUT:\n{out}\nSTDERR:\n{err}" |
|
raise RuntimeError(msg) |
|
|
|
|
|
def ensure_flat_directory(src: Path) -> list[Path]: |
|
entries = list(src.iterdir()) |
|
subdirs = [p for p in entries if p.is_dir() and p.name != ".git"] |
|
if subdirs: |
|
names = ", ".join(sorted(p.name for p in subdirs)) |
|
raise RuntimeError(f"Directory is not flat (subdirectories found: {names}). Gists cannot contain folders.") |
|
files = [p for p in entries if p.is_file()] |
|
if not files: |
|
raise RuntimeError("No files to gistify in the directory.") |
|
return files |
|
|
|
|
|
def create_empty_gist(make_public: bool) -> str: |
|
# GitHub requires at least one file when creating a gist. |
|
# Create with a tiny placeholder, then we will overwrite/remove it after cloning. |
|
cmd = ["gh", "gist", "create", "-f", ".placeholder"] |
|
if make_public: |
|
cmd.append("--public") |
|
url = run(cmd, input_text="temporary placeholder; will be replaced") |
|
if not url.startswith("http"): |
|
raise RuntimeError(f"Unexpected output from gh gist create: {url}") |
|
return url |
|
|
|
|
|
def _gist_id_from_url(url: str) -> str: |
|
"""Extract the gist ID from a gist URL string.""" |
|
parsed = urlparse(url) |
|
# Path may be like "/<user>/<id>" or just "/<id>" |
|
parts = [p for p in parsed.path.split("/") if p] |
|
if not parts: |
|
raise RuntimeError(f"Cannot parse gist id from URL: {url}") |
|
gist_id = parts[-1] |
|
# basic sanity: gist ids are hex-ish; don't strictly enforce |
|
return gist_id |
|
|
|
|
|
def clone_gist(url: str) -> Path: |
|
# Prefer SSH to leverage user's SSH key without prompting for username/password |
|
gist_id = _gist_id_from_url(url) |
|
clone_url = f"[email protected]:{gist_id}.git" |
|
tmpdir = Path(tempfile.mkdtemp(prefix="gistify-")) |
|
run(["git", "clone", clone_url, str(tmpdir)]) |
|
return tmpdir |
|
|
|
|
|
def copy_files(files: list[Path], dest: Path) -> None: |
|
for f in files: |
|
shutil.copy2(str(f), dest / f.name) |
|
|
|
|
|
def commit_and_push(repo_dir: Path, src_dir: Path) -> None: |
|
# Remove placeholder if present |
|
placeholder = repo_dir / ".placeholder" |
|
if placeholder.exists(): |
|
try: |
|
placeholder.unlink() |
|
except OSError: |
|
pass |
|
run(["git", "add", "-A"], cwd=repo_dir) |
|
# Commit; handle no-op commit gracefully |
|
try: |
|
run(["git", "commit", "-m", f"Add files from {src_dir}"] , cwd=repo_dir) |
|
except RuntimeError as e: |
|
# If nothing to commit, ignore; otherwise re-raise |
|
if "nothing to commit" not in str(e).lower(): |
|
raise |
|
run(["git", "push"], cwd=repo_dir) |
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace: |
|
parser = argparse.ArgumentParser( |
|
description=( |
|
"Create a GitHub Gist from a flat directory using gh and git." |
|
) |
|
) |
|
vis = parser.add_mutually_exclusive_group() |
|
vis.add_argument("--public", action="store_true", help="Create a public gist") |
|
vis.add_argument( |
|
"--secret", |
|
action="store_true", |
|
help="Create a secret gist (default)", |
|
) |
|
parser.add_argument( |
|
"directory", |
|
nargs="?", |
|
default=".", |
|
help="Directory to gistify (defaults to current directory)", |
|
) |
|
return parser.parse_args(argv) |
|
|
|
|
|
def main(argv: list[str]) -> int: |
|
which_or_die("gh") |
|
which_or_die("git") |
|
|
|
args = parse_args(argv) |
|
make_public = bool(args.public) and not bool(args.secret) |
|
|
|
src = Path(args.directory).expanduser().resolve() |
|
if not src.exists() or not src.is_dir(): |
|
print(f"Error: path is not a directory: {src}", file=sys.stderr) |
|
return 2 |
|
|
|
try: |
|
files = ensure_flat_directory(src) |
|
gist_url = create_empty_gist(make_public) |
|
repo_dir: Optional[Path] = None |
|
try: |
|
repo_dir = clone_gist(gist_url) |
|
copy_files(files, repo_dir) |
|
commit_and_push(repo_dir, src) |
|
finally: |
|
if repo_dir and repo_dir.exists(): |
|
shutil.rmtree(repo_dir, ignore_errors=True) |
|
except RuntimeError as e: |
|
print(str(e), file=sys.stderr) |
|
return 1 |
|
|
|
print(gist_url) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
raise SystemExit(main(sys.argv[1:])) |