This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
Git History On A Piperead← all gifts

Gitlog

Turn a git history into one JSON object per commit on stdout, so the questions you actually have — how many commits touched this file, who authored what last week, churn per day — become one pipe away instead of re-parsing git's text yourself. Field names match what git-log folds already read.

The honest edge
It reports exactly what git reports — it's only as complete as the history you point it at. A shallow clone gives you a shallow answer, faithfully.
Run it
python3 gitlog.py --repo . test_gitlog.py (19/19) Python stdlib only
The code — every file that ships
gitlog.py269 lineson GitHub →
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""gitlog.py — turn a git history into one JSON object per commit, on stdout.

A git repository already holds the truth about what happened and when: every
commit is a dated, authored, immutable record. But that truth is trapped behind
`git log`'s human-facing format. This reads the log and emits it as **JSON
lines** — one object per commit, newest first by default — so the next tool in a
pipe can fold, filter, count, or chart it without re-parsing git's output itself.

It is a *source*: it emits records and consumes no stdin. Point it at a repo, get
a clean stream of commits you can reason about. The whole point is to stop
eyeballing `git log` and start folding it: "how many commits touched this file",
"who authored what in this range", "what was the churn per day" all become one
pipe away once the history is on the rail as records.

Each commit becomes one object with these fields (always present):
    hash        full 40-char commit SHA            (git %H)
    short       abbreviated SHA                     (git %h)
    committed   committer date, ISO-8601 strict     (git %cI)
    authored    author date, ISO-8601 strict        (git %aI)
    author      author name                         (git %an)
    email       author email                        (git %ae)
    subject     first line of the message           (git %s)

With --churn, three more fields are added per commit (one extra git call each):
    files       number of files changed in the commit   (int)
    added       total inserted lines across those files  (int, None if binary-only)
    deleted     total deleted lines                       (int, None if binary-only)

The field names match what the existing git-log folds already read (%H, %h, %cI,
%aI, %s), so a fold written against those consumes this source's output directly.

------------------------------------------------------------------------------
The JSON-lines contract (so this composes in a pipe):
  - emits ONE JSON object per line on stdout — a commit record.
  - order is git's default (reverse-chronological) unless --reverse is given.
  - --path P restricts to commits that touched P (repeatable).
  - --since / --until / --author / --max-count are passed through to git log.
  - errors go to stderr; stdout stays clean JSON-lines.

Exit codes:
    0   the log was read and emitted (including the empty-history case)
    2   git failed / not a repository / git not found (don't trust the reading)
    3   usage / bad input
"""

import argparse
import json
import subprocess
import sys


# GIFT-013: field/record framing must use a byte that CANNOT occur in commit
# text. The old 0x1f/0x1e (ASCII unit/record separators) were assumed "safe
# inside commit text" but are not — git permits those control bytes inside a
# subject or author name, so a crafted commit split one record into many
# ("malformed log record: expected 7 fields, got 8"). NUL is the only byte git
# guarantees absent from commit content (its objects are NUL-terminated
# C-strings): fields are joined with %x00 and each record is terminated by
# `git log -z`, so no field value can ever break the frame.
NUL = "\x00"

# The per-commit fields, in emit order. (placeholder, json_key) pairs.
FIELDS = [
    ("%H", "hash"),
    ("%h", "short"),
    ("%cI", "committed"),
    ("%aI", "authored"),
    ("%an", "author"),
    ("%ae", "email"),
    ("%s", "subject"),
]


class GitLogError(Exception):
    """A git fault or usage error, carrying an exit code."""

    def __init__(self, message, code):
        super().__init__(message)
        self.code = code


def _run_git(args, repo):
    """Run a git command in repo, return stdout text. Raise GitLogError on failure."""
    try:
        proc = subprocess.run(
            ["git", "-C", repo] + args,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            encoding="utf-8",
        )
    except FileNotFoundError:
        raise GitLogError("git executable not found on PATH", 2)
    if proc.returncode != 0:
        detail = proc.stderr.strip() or "git exited %d" % proc.returncode
        raise GitLogError(detail, 2)
    return proc.stdout


def read_commits(repo, ref="HEAD", paths=None, since=None, until=None,
                 author=None, max_count=None, reverse=False):
    """Read commits from repo and return a list of record dicts (no churn).

    The list is git's default order (reverse-chronological) unless reverse=True.
    An empty history returns [] and is NOT an error.
    """
    # GIFT-014: an unborn repository (git init, zero commits) has no valid HEAD,
    # so `git log HEAD` fatals. The contract says an empty history returns [] and
    # is not an error. When the ref is the DEFAULT HEAD, detect the unborn case
    # with a quiet verify and honor that contract. An invalid EXPLICIT ref
    # (ref != "HEAD") still errors, and stays distinguishable from an unborn HEAD.
    if ref == "HEAD":
        probe = subprocess.run(
            ["git", "-C", repo, "rev-parse", "--verify", "--quiet", "HEAD"],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8",
        )
        if probe.returncode != 0:
            inside = subprocess.run(
                ["git", "-C", repo, "rev-parse", "--git-dir"],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8",
            )
            if inside.returncode == 0:
                return []  # unborn default HEAD -> empty history, per contract
            # not a git repo / unreadable -> fall through; _run_git raises properly.

    # Fields joined by a literal NUL (%x00); `-z` NUL-terminates each record.
    fmt = "%x00".join(p for p, _ in FIELDS)
    args = ["log", ref, "-z", "--format=%s" % fmt]
    if reverse:
        args.append("--reverse")
    if since:
        args.append("--since=%s" % since)
    if until:
        args.append("--until=%s" % until)
    if author:
        args.append("--author=%s" % author)
    if max_count is not None:
        args.append("--max-count=%d" % max_count)
    if paths:
        args.append("--")
        args.extend(paths)

    raw = _run_git(args, repo)
    # -z terminates every record (including the last) with a NUL, and fields are
    # NUL-joined, so the whole stream is NUL-separated tokens: every len(FIELDS)
    # tokens is one record. Drop the single trailing empty left by the final
    # terminator; interior empty tokens (e.g. an empty subject) are preserved.
    tokens = raw.split(NUL)
    if tokens and tokens[-1] == "":
        tokens.pop()
    if not tokens:
        return []
    n = len(FIELDS)
    if len(tokens) % n != 0:
        # A malformed stream is a fault, not a silent drop.
        raise GitLogError(
            "malformed log stream: %d field tokens is not a multiple of %d"
            % (len(tokens), n),
            2,
        )
    records = []
    for i in range(0, len(tokens), n):
        group = tokens[i:i + n]
        rec = {key: group[j] for j, (_, key) in enumerate(FIELDS)}
        records.append(rec)
    return records


def add_churn(repo, record):
    """Add files/added/deleted to one record via `git show --numstat`.

    added/deleted are None when the commit is binary-only (git prints '-').
    Mutates and returns the record.
    """
    out = _run_git(
        ["show", "--numstat", "--format=", record["hash"]], repo
    )
    files = 0
    added = 0
    deleted = 0
    saw_binary = False
    for line in out.splitlines():
        line = line.strip()
        if not line:
            continue
        cols = line.split("\t")
        if len(cols) < 3:
            continue
        files += 1
        a, d = cols[0], cols[1]
        if a == "-" or d == "-":
            saw_binary = True
            continue
        try:
            added += int(a)
            deleted += int(d)
        except ValueError:
            saw_binary = True
    record["files"] = files
    # If every changed file was binary, added/deleted are not meaningful.
    if saw_binary and added == 0 and deleted == 0:
        record["added"] = None
        record["deleted"] = None
    else:
        record["added"] = added
        record["deleted"] = deleted
    return record


def _emit(record, out=None):
    """Write one JSON object as a single line to stdout (or the given stream)."""
    stream = out if out is not None else sys.stdout
    stream.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")


def build_parser():
    p = argparse.ArgumentParser(
        prog="gitlog",
        description="Emit a git history as JSON lines, one object per commit.",
    )
    p.add_argument("--repo", default=".", help="path to the git repo (default: .)")
    p.add_argument("--ref", default="HEAD",
                   help="ref/range to log (default: HEAD; e.g. main, v1..v2)")
    p.add_argument("--path", action="append", dest="paths", metavar="P",
                   help="restrict to commits touching P (repeatable)")
    p.add_argument("--since", help="git --since passthrough (e.g. '2 weeks ago')")
    p.add_argument("--until", help="git --until passthrough")
    p.add_argument("--author", help="git --author filter passthrough")
    p.add_argument("-n", "--max-count", type=int, default=None,
                   help="limit to the most recent N commits")
    p.add_argument("--reverse", action="store_true",
                   help="oldest first (default is newest first)")
    p.add_argument("--churn", action="store_true",
                   help="add files/added/deleted per commit (one extra git call each)")
    return p


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.max_count is not None and args.max_count < 1:
        sys.stderr.write("gitlog: -n must be >= 1\n")
        return 3

    try:
        records = read_commits(
            args.repo,
            ref=args.ref,
            paths=args.paths,
            since=args.since,
            until=args.until,
            author=args.author,
            max_count=args.max_count,
            reverse=args.reverse,
        )
        for rec in records:
            if args.churn:
                add_churn(args.repo, rec)
            _emit(rec)
        return 0
    except GitLogError as exc:
        sys.stderr.write("gitlog: %s\n" % exc)
        return exc.code


if __name__ == "__main__":
    sys.exit(main())
test_gitlog.py168 lineson GitHub →
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Tests for gitlog.py — run against a REAL throwaway git repo, not a mock.

The point of a source gift is that it reads a real git log correctly, so the
tests build an actual repository in a temp dir and assert on the emitted records.
Each test is written to BITE: it asserts specific values a broken implementation
would get wrong (field mapping, ordering, churn arithmetic, path filtering),
not merely that "some output appeared".

Run:  python3 test_gitlog.py       (exits non-zero if any assertion fails)
"""

import io
import json
import os
import subprocess
import sys
import tempfile

import gitlog


def _git(repo, *args, env=None):
    subprocess.run(["git", "-C", repo] + list(args), check=True,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)


def _commit(repo, path, content, message, when):
    """Write a file and commit it with a fixed author/committer date."""
    full = os.path.join(repo, path)
    os.makedirs(os.path.dirname(full), exist_ok=True) if os.path.dirname(full) else None
    with open(full, "w", encoding="utf-8") as fh:
        fh.write(content)
    _git(repo, "add", path)
    env = dict(os.environ)
    env["GIT_AUTHOR_DATE"] = when
    env["GIT_COMMITTER_DATE"] = when
    env["GIT_AUTHOR_NAME"] = "Ada Lovelace"
    env["GIT_AUTHOR_EMAIL"] = "ada@example.com"
    env["GIT_COMMITTER_NAME"] = "Ada Lovelace"
    env["GIT_COMMITTER_EMAIL"] = "ada@example.com"
    subprocess.run(["git", "-C", repo, "commit", "-m", message],
                   check=True, stdout=subprocess.DEVNULL,
                   stderr=subprocess.DEVNULL, env=env)


def _make_repo(tmp):
    repo = os.path.join(tmp, "r")
    os.makedirs(repo)
    _git(repo, "init")
    _git(repo, "config", "user.name", "Ada Lovelace")
    _git(repo, "config", "user.email", "ada@example.com")
    # three commits, oldest first
    _commit(repo, "a.txt", "one\ntwo\nthree\n", "first: add a.txt",
            "2026-01-01T09:00:00")
    _commit(repo, "b.txt", "x\n", "second: add b.txt",
            "2026-01-02T09:00:00")
    _commit(repo, "a.txt", "one\ntwo\nthree\nfour\n", "third: extend a.txt",
            "2026-01-03T09:00:00")
    return repo


def _run(argv):
    """Run main() capturing stdout; return (exit_code, [parsed records])."""
    buf = io.StringIO()
    old = sys.stdout
    sys.stdout = buf
    try:
        code = gitlog.main(argv)
    finally:
        sys.stdout = old
    records = [json.loads(l) for l in buf.getvalue().splitlines() if l.strip()]
    return code, records


RESULTS = []


def check(name, cond):
    RESULTS.append((name, bool(cond)))
    print(("PASS" if cond else "FAIL") + "  " + name)


def main():
    tmp = tempfile.mkdtemp(prefix="gitlog-test-")
    repo = _make_repo(tmp)

    # 1. count: three commits in, three records out.
    code, recs = _run(["--repo", repo])
    check("emits one record per commit (3)", code == 0 and len(recs) == 3)

    # 2. default order is newest-first — the third commit leads.
    check("default order newest-first",
          recs[0]["subject"] == "third: extend a.txt"
          and recs[2]["subject"] == "first: add a.txt")

    # 3. --reverse flips to oldest-first. (Bites an ignored --reverse.)
    code, rrecs = _run(["--repo", repo, "--reverse"])
    check("--reverse gives oldest-first",
          rrecs[0]["subject"] == "first: add a.txt"
          and rrecs[2]["subject"] == "third: extend a.txt")

    # 4. field mapping: author/email/dates land in the right keys.
    #    (Bites a transposed field map — the classic %an/%ae swap.)
    top = recs[0]
    check("author name mapped correctly", top["author"] == "Ada Lovelace")
    check("email mapped correctly", top["email"] == "ada@example.com")
    check("committed date is the fixed ISO date",
          top["committed"].startswith("2026-01-03T09:00:00"))
    check("hash is 40 hex chars", len(top["hash"]) == 40
          and all(c in "0123456789abcdef" for c in top["hash"]))
    check("short is a prefix of hash", top["hash"].startswith(top["short"]))

    # 5. no churn fields unless asked. (Bites churn leaking in by default.)
    check("no churn fields by default", "added" not in top and "files" not in top)

    # 6. --churn arithmetic: the first commit added 3 lines to a.txt, 0 deleted.
    code, crecs = _run(["--repo", repo, "--reverse", "--churn"])
    first = crecs[0]
    check("churn: files counted", first.get("files") == 1)
    check("churn: added lines correct (3)", first.get("added") == 3)
    check("churn: deleted lines correct (0)", first.get("deleted") == 0)
    # third commit: +1 line, -0 on a.txt
    third = crecs[2]
    check("churn: extend commit added 1", third.get("added") == 1
          and third.get("deleted") == 0)

    # 7. --path filters to commits touching that path.
    #    a.txt was touched by commits 1 and 3 only (not 2). Bites a broken filter.
    code, precs = _run(["--repo", repo, "--path", "a.txt"])
    subjects = {r["subject"] for r in precs}
    check("--path a.txt selects only its 2 commits",
          len(precs) == 2 and "second: add b.txt" not in subjects)

    # 8. -n limits count. Bites an ignored max-count.
    code, nrecs = _run(["--repo", repo, "-n", "1"])
    check("-n 1 yields exactly one record", code == 0 and len(nrecs) == 1)

    # 9. not-a-repo is exit 2, not a crash and not exit 0.
    code, _ = _run(["--repo", tmp])  # tmp itself is not a git repo
    check("non-repo path exits 2", code == 2)

    # 10. -n 0 is a usage error (exit 3).
    code, _ = _run(["--repo", repo, "-n", "0"])
    check("-n 0 is usage error (exit 3)", code == 3)

    # 11. stdout is clean JSON-lines: every emitted line parses as a JSON object.
    code, recs = _run(["--repo", repo])
    check("every line is a JSON object",
          all(isinstance(r, dict) for r in recs) and len(recs) == 3)

    # 12. empty range is exit 0 with zero records (not an error).
    code, erecs = _run(["--repo", repo, "--since", "2030-01-01"])
    check("empty result is exit 0, zero records", code == 0 and erecs == [])

    # --- meta: the suite must not be vacuous ---
    ran = len(RESULTS)
    passed = sum(1 for _, ok in RESULTS if ok)
    print("\n%d/%d checks passed (%d ran)" % (passed, ran, ran))
    if ran < 12:
        print("VACUITY GUARD FAILED: too few checks ran")
        return 1
    return 0 if passed == ran else 1


if __name__ == "__main__":
    sys.exit(main())
Take the whole folder → MIT Python stdlib only