Last active
March 4, 2026 20:21
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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