Skip to content

Instantly share code, notes, and snippets.

@quiin
Last active March 4, 2026 20:21
Show Gist options
  • Select an option

  • Save quiin/694813939a62ddebe85f28e5e272874f to your computer and use it in GitHub Desktop.

Select an option

Save quiin/694813939a62ddebe85f28e5e272874f to your computer and use it in GitHub Desktop.
Git changelog as markdown. Summarize git changes from a given window and optional scope as markdown
#!/usr/bin/env python3
import argparse
import subprocess
import sys
from datetime import datetime
from dataclasses import dataclass, field
from typing import Iterable, List
MARKER = "__GA_COMMIT__"
@dataclass
class CommitEntry:
short_hash: str
date: str
subject: str
files: List[str] = field(default_factory=list)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Print changelog entries from git commits in a time window and scope (optional). Commit entries are sorted (most recent first) and condensed to one line with a preview of changed files. Output is formatted for markdown."
)
parser.add_argument("--since", '--from', required=True, help="Git --since value (e.g. `2026-03-01`)")
parser.add_argument("--until", '--to', required=False, help="Git --until value (e.g. `2026-03-04`)", default="now")
parser.add_argument("--scope", nargs="+", default=[], help="Paths to scope commit collection (e.g. `path/to/folder`)",)
parser.add_argument("--max-files", type=int, default=3, help="Max changed files listed per commit",)
return parser.parse_args()
def run_git_log(since: str, until: str, scope: Iterable[str]) -> str:
cmd = [
"git",
"--no-pager",
"log",
f"--since={since}",
f"--until={until}",
"--date=short",
f"--pretty=format:{MARKER}%n%h|%ad|%s",
"--name-only",
"--",
*scope,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "git log failed")
return result.stdout
def parse_commits(raw_log: str) -> List[CommitEntry]:
commits: List[CommitEntry] = []
current: CommitEntry | None = None
for raw_line in raw_log.splitlines():
line = raw_line.strip()
if not line:
continue
if line == MARKER:
if current is not None:
commits.append(current)
current = None
continue
if current is None:
parts = line.split("|", 2)
if len(parts) != 3:
continue
current = CommitEntry(short_hash=parts[0], date=parts[1], subject=parts[2])
continue
current.files.append(line)
if current is not None:
commits.append(current)
return commits
def simplify_file_path(path: str, scope: Iterable[str]) -> str:
for prefix in scope:
if path.startswith(prefix):
prefix_core = prefix.split("/")[-1]
return prefix_core + path[len(prefix):]
return path
def format_commit_line(commit: CommitEntry, max_files: int, scope: Iterable[str]) -> str:
cleaned_subject = commit.subject.strip().rstrip(".")
file_list = [simplify_file_path(p, scope) for p in commit.files]
preview = file_list[:max_files]
files_text = ", ".join(preview)
if len(file_list) > max_files:
files_text += f", +{len(file_list) - max_files} more"
if files_text:
return f"- {cleaned_subject} ({files_text})."
return f"- {cleaned_subject}."
def build_section(since: str, until: str, commits: List[CommitEntry], max_files: int, scope: Iterable[str]) -> str | None:
if not commits:
return None
lines = [f"## {since} to {until}", "", "### Commits (condensed)"]
for commit in commits:
lines.append(format_commit_line(commit, max_files=max_files, scope=scope))
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
args = parse_args()
raw_until = args.until if args.until != "now" else "now"
until = datetime.now().strftime("%Y-%m-%d") if raw_until == "now" else raw_until
try:
raw_log = run_git_log(args.since, until, args.scope)
commits = parse_commits(raw_log)
section = build_section(args.since, until, commits, args.max_files, args.scope)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if section is None:
msg = f"No commits found between {args.since} and {until}."
if args.scope:
msg += f' Scoped to: {" ".join(args.scope)}.'
print(msg)
return 0
print(section)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment