Files
wow20/.claude/skills/bmad-retrospective/scripts/tests/test_git_evidence.py
T
2026-08-14 17:08:24 +02:00

751 lines
28 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for git_evidence.py — measurement over a real temp git repo.
Run: uv run scripts/tests/test_git_evidence.py
or: uv run --with pytest -m pytest scripts/tests/test_git_evidence.py
"""
import json
import os
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "git_evidence.py"
# The fixture commit identity, shared by both git helpers below.
_IDENT = {
"GIT_AUTHOR_NAME": "T",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "T",
"GIT_COMMITTER_EMAIL": "t@t",
}
def _git_env(repo):
"""The environment every fixture git runs under, layered outward.
Inherit the real environment (PATH above all: git lives in /opt/homebrew,
/usr/local, or a nix store as readily as /usr/bin, and an env holding only
GIT_* vars sends execvp to os.defpath), then strip every ambient GIT_* var
-- GIT_DIR, GIT_WORK_TREE and GIT_CONFIG_COUNT would each silently redirect
or reconfigure the fixture -- and pin identity plus every source git reads
for settings, so nothing on the developer's machine can reach the fixture:
- gitconfig (commit.gpgsign, core.autocrlf, core.hooksPath,
init.defaultBranch). GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL cover
git >= 2.32; HOME and XDG_CONFIG_HOME cover older git, and are set to the
repo's parent directory -- always a per-test directory under pytest's
tmp_path -- so nothing is ever planted inside the working tree.
- gitattributes, a separate source GIT_CONFIG_NOSYSTEM does not cover: a
system `* -diff` rule would make numstat call every path binary and take
the churn assertions down with it. GIT_ATTR_NOSYSTEM shuts it out.
- the locale. LC_ALL/LANG are pinned to C, matching _run/_proc's existing
pin, so fixture git's text output cannot vary with the developer's
locale. Inheriting the environment is what makes this pin necessary:
the old four-variable env had no locale in it to inherit.
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(_IDENT)
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_ATTR_NOSYSTEM"] = "1"
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["HOME"] = env["XDG_CONFIG_HOME"] = str(Path(repo).parent)
env["LC_ALL"] = env["LANG"] = "C"
return env
def _json(proc):
"""Parse the JSON-only stdout contract, surfacing a crash instead of hiding
it behind a JSONDecodeError."""
assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}"
assert "Traceback" not in proc.stderr, proc.stderr
return json.loads(proc.stdout)
def _run(*args):
# LC_ALL=C keeps git's error strings in English so assertions on them
# are stable across locales.
proc = subprocess.run(
["uv", "run", str(SCRIPT), *args],
capture_output=True,
text=True,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
return proc.returncode, _json(proc)
def _proc(*args, env=None):
"""Run the script and return the raw process, so a test can assert on the
exit code and stderr together — and so `env` can carry an overlay (a fake
`git` earlier on PATH) that `_run` has no way to pass."""
overlay = {"LC_ALL": "C", "LANG": "C"}
if env:
overlay.update(env)
return subprocess.run(
["uv", "run", str(SCRIPT), *args],
capture_output=True,
text=True,
env={**os.environ, **overlay},
)
def _git(repo, *args):
subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
env=_git_env(repo),
)
def _git_unchecked(repo, *args):
"""`git` that tolerates a non-zero exit — the conflicting merge in
`_merge_repo` is supposed to fail, and the hand resolution comes after it.
Same environment as `_git`, which runs with check=True."""
return subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
env=_git_env(repo),
)
def _make_repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
(repo / "a.py").write_text("one\ntwo\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-1 initial a")
(repo / "a.py").write_text("one\ntwo\nthree\nfour\n")
(repo / "b.py").write_text("x\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-2 grow a, add b")
return repo
def test_no_range_returns_empty(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo))
assert code == 0
assert out["range"] is None
assert out["commits"] == [] and out["files"] == []
def test_measures_commits_and_files_with_attribution(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run(
"--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-1"
)
assert code == 0
assert out["range"] == "HEAD~1..HEAD"
assert out["commit_count"] == 1
# The single commit in range is the second one; attributed to story "1-2".
assert out["commits"][0]["stories"] == ["1-2"]
files = {f["path"]: f for f in out["files"]}
# a.py grew by two lines, b.py added one — measured, not judged.
assert files["a.py"]["added"] == 2 and files["a.py"]["net"] == 2
assert files["b.py"]["added"] == 1
def test_story_attribution_respects_word_boundary(tmp_path):
# Story id "1-2" must NOT match a commit subject mentioning "11-2".
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-11-2 unrelated story")
code, out = _run("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2")
assert code == 0
assert out["commits"][0]["stories"] == []
def test_bad_range_errors_as_json(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "nope..alsonope")
assert code == 1
assert out["ok"] is False and out["error"]
def test_single_rev_range_rejected(tmp_path):
# A single rev is not a range: git would log ALL history up to it and the
# script would report the whole repo as the epic's evidence.
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "HEAD")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
def test_pathspec_range_rejected(tmp_path):
# A path that exists must not be silently consumed as a pathspec.
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "a.py")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
def test_range_shaped_pathspec_forced_to_rev_parse(tmp_path):
# A committed file literally named "a..b" passes the REV..REV shape check;
# without the trailing "--" in the git argv, git silently logs that FILE's
# history with exit 0. The "--" forces rev interpretation, so this must
# error instead of measuring the decoy.
repo = _make_repo(tmp_path)
(repo / "a..b").write_text("decoy\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "add decoy file named like a range")
code, out = _run("--repo", str(repo), "--range", "a..b")
assert code == 1
assert out["ok"] is False and "bad revision" in out["error"]
def test_option_like_range_rejected(tmp_path):
# A range starting with "-" must never reach git, where it would be
# consumed as an option (e.g. --output=... writes an arbitrary file).
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range=--output=evil.txt")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
assert not (repo / "evil.txt").exists()
def test_degenerate_range_shapes_rejected(tmp_path):
# Shapes that contain ".." but are not REV..REV: git would silently
# default an empty endpoint to HEAD ("..", "a..", "..HEAD"), a leading
# dash must never reach git even when dots are present ("-3..HEAD"),
# unstripped values must not slip past the dash guard, and a three-dot
# range is a symmetric difference — git would measure commits reachable
# from either endpoint but not both, a different evidence set entirely.
repo = _make_repo(tmp_path)
for bad in (
"..",
"a..",
"..HEAD",
"-3..HEAD",
" HEAD~1..HEAD",
"HEAD~1...HEAD",
"a...b",
):
code, out = _run("--repo", str(repo), f"--range={bad}")
assert code == 2, f"accepted {bad!r}"
assert out["ok"] is False and "invalid --range" in out["error"], bad
def test_malformed_args_emit_json_not_usage(tmp_path):
# An unknown flag must still land on the JSON contract, not argparse's
# plain usage text on stderr.
code, out = _run("--bogus-flag")
assert code != 0
assert out["ok"] is False and out["error"]
def test_help_flags_emit_json_not_usage():
# argparse's built-in help action bypasses the error() override entirely --
# it prints usage text on stdout and exits 0, which breaks the JSON-only
# contract for a machine consumer. add_help=False demotes -h to an ordinary
# unrecognized argument, which error() already handles.
for flag in ("-h", "--help"):
proc = _proc(flag)
# Exit 2 specifically: the module docstring reserves 2 for argument
# errors and 1 for git/I-O failures, so collapsing them must fail here.
assert proc.returncode == 2, flag
assert "usage:" not in proc.stdout, flag
out = _json(proc)
assert out["ok"] is False and out["error"], flag
# --- helpers for the fixtures below -----------------------------------------
def _rev(repo, ref):
return _git_unchecked(repo, "rev-parse", ref).stdout.strip()
def _fake_git(tmp_path, body):
"""Write a `git` shim and return the PATH overlay that puts it ahead of the
real binary for the script's own subprocesses (never for the fixtures,
which build their repos through `_git`'s own environment)."""
bindir = tmp_path / "fakebin"
bindir.mkdir()
shim = bindir / "git"
shim.write_text(body)
os.chmod(shim, 0o755)
return {"PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}
def _merge_repo(tmp_path):
"""Two story branches merged into the mainline; the second merge conflicts
and is resolved by hand, adding a line neither branch had. Returns
(repo, base_sha) — `base_sha..HEAD` is the epic range."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "s.py").write_text("l1\nl2\nl3\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
_git(repo, "checkout", "-q", "-b", "s1")
(repo / "s.py").write_text("l1\nA\nl3\n")
_git(repo, "commit", "-qam", "epic-1-2 story one")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
_git(repo, "checkout", "-q", "-b", "s2", base)
(repo / "s.py").write_text("l1\nB\nl3\n")
_git(repo, "commit", "-qam", "epic-1-3 story two")
_git(repo, "checkout", "-q", "main")
conflicted = _git_unchecked(repo, "merge", "--no-ff", "s2", "-m", "merge story 1-3")
assert conflicted.returncode != 0, "fixture expected a merge conflict"
(repo / "s.py").write_text("l1\nAB\nl3\nl4\n") # hand resolution
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "merge story 1-3")
return repo, base
def test_rename_yields_two_openable_paths(tmp_path):
# git's default rename detection emits "src/{mod.py => renamed.py}" — an
# unopenable pseudo-path that also splits one file's churn across keys.
# --no-renames makes the rename an honest delete + add.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "src").mkdir()
(repo / "src" / "mod.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
_git(repo, "mv", "src/mod.py", "src/renamed.py")
_git(repo, "commit", "-qm", "rename mod")
(repo / "src" / "renamed.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\n")
_git(repo, "commit", "-qam", "add a line after the rename")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
files = {f["path"]: f for f in out["files"]}
assert not any("=>" in path for path in files), sorted(files)
assert files["src/mod.py"]["added"] == 0
assert files["src/mod.py"]["deleted"] == 6
assert files["src/renamed.py"]["added"] == 7
assert files["src/renamed.py"]["deleted"] == 0
def _accented_repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "src").mkdir()
(repo / "src" / "café.py").write_text("ca\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "src" / "café.py").write_text("ca\ncb\n")
_git(repo, "commit", "-qam", "touch the accented file")
return repo, base
def _assert_accented_path(repo, out):
paths = [f["path"] for f in out["files"]]
assert len(paths) == 1
assert "\\" not in paths[0] and '"' not in paths[0], paths
assert unicodedata.normalize("NFC", paths[0]) == "src/café.py"
# The reported path is a real path: it opens under --repo.
assert (repo / paths[0]).read_text() == "ca\ncb\n"
def test_non_ascii_path_is_a_real_string(tmp_path):
# Without core.quotePath=false git emits "src/caf\303\251.py" — quoted and
# octal-escaped, so the documented "open the ranked files" step cannot.
repo, base = _accented_repo(tmp_path)
_assert_accented_path(
repo, _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
)
def test_non_ascii_path_survives_a_non_utf8_locale(tmp_path):
# Pins the explicit encoding="utf-8" on the subprocess. Modern CPython's
# UTF-8 mode hides its absence even under LC_ALL=C, so the pin only bites
# with UTF-8 mode and C-locale coercion both off — where the interpreter
# default is US-ASCII and git's UTF-8 path bytes fail to decode, taking the
# whole measurement down with them.
repo, base = _accented_repo(tmp_path)
out = _json(
_proc(
"--repo",
str(repo),
"--range",
f"{base}..HEAD",
env={"PYTHONUTF8": "0", "PYTHONCOERCECLOCALE": "0"},
)
)
_assert_accented_path(repo, out)
def test_merge_churn_is_measured_and_counted(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["commit_count"] == 4
assert out["merge_count"] == 2
assert out["merges_measured"] == 2
# Both merges' first-parent churn: 1/1 for the clean merge, 2/1 for the
# hand-resolved one (the resolution added a line neither branch had).
merge_files = {f["path"]: f for f in out["merge_files"]}
assert merge_files["s.py"]["added"] == 3
assert merge_files["s.py"]["deleted"] == 2
assert merge_files["s.py"]["net"] == 1
assert merge_files["s.py"]["commit_count"] == 2
def test_merge_commits_listed_but_excluded_from_files(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
by_subject = {c["subject"]: c for c in out["commits"]}
assert by_subject["merge story 1-2"]["is_merge"] is True
assert by_subject["merge story 1-3"]["is_merge"] is True
assert by_subject["epic-1-2 story one"]["is_merge"] is False
# `files` is the two story commits only — 1/1 each. Folding the merges in
# would double count: their diff restates the churn they merged.
files = {f["path"]: f for f in out["files"]}
assert files["s.py"]["added"] == 2
assert files["s.py"]["deleted"] == 2
assert files["s.py"]["commit_count"] == 2
def test_story_attribution_survives_merges(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(
_proc(
"--repo", str(repo), "--range", f"{base}..HEAD", "--stories", "1-2,1-3"
)
)
by_subject = {c["subject"]: c for c in out["commits"]}
assert by_subject["epic-1-2 story one"]["stories"] == ["1-2"]
assert by_subject["epic-1-3 story two"]["stories"] == ["1-3"]
def test_off_spine_merge_is_counted_but_not_measured(tmp_path):
# A back-merge of the mainline into a story branch is a merge in the range,
# but its first-parent diff would restate unrelated mainline content as
# epic churn. It stays in merge_count and out of merges_measured, so the
# gap between the two says plainly that a merge went unmeasured.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.txt").write_text("base\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "f.txt").write_text("base\nmain1\n")
_git(repo, "commit", "-qam", "mainline work")
_git(repo, "checkout", "-q", "-b", "s1", base)
(repo / "s1.txt").write_text("s1\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-2 story one")
_git(repo, "merge", "-q", "--no-ff", "main", "-m", "back-merge main into s1")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["merge_count"] == 2
assert out["merges_measured"] == 1
merge_files = {f["path"]: f for f in out["merge_files"]}
assert set(merge_files) == {"s1.txt"}
def test_merge_pass_survives_a_hostile_log_diffmerges_config(tmp_path):
# `-m` means "whatever log.diffMerges says", so a user or repo config of
# `off` makes the merge pass emit no file rows: merge_files comes back
# empty beside a non-zero merges_measured and reads as "the merges changed
# nothing". The command-line -c pin beats the config.
repo, base = _merge_repo(tmp_path)
_git(repo, "config", "log.diffMerges", "off")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["merges_measured"] == 2
merge_files = {f["path"]: f for f in out["merge_files"]}
assert merge_files["s.py"]["added"] == 3
assert merge_files["s.py"]["deleted"] == 2
def test_distinct_non_utf8_paths_stay_distinct(tmp_path):
# errors="replace" maps every invalid byte to the same U+FFFD, collapsing
# two different files into one `files` key with their churn summed —
# measurement corruption with nothing in the output admitting to it.
overlay = _fake_git(
tmp_path,
"#!/bin/sh\n"
"printf 'aaaa\\037\\037subj\\n\\n1\\t0\\tsrc/caf\\351.py\\n"
"2\\t0\\tsrc/caf\\377.py\\n'\n",
)
out = _json(_proc("--repo", str(tmp_path), "--range", "a..b", env=overlay))
files = {f["path"]: f for f in out["files"]}
assert len(files) == 2, files
assert sorted(f["added"] for f in files.values()) == [1, 2]
def test_repeated_merge_headers_are_counted_once(tmp_path):
# Pins the dedupe guard in _parse_log. git before 2.31 does not honour
# --first-parent for `-m`'s diff format, so a merge's header repeats once
# per parent with a diff block under each. Without the guard that doubles
# the merge churn and pushes merges_measured above merge_count, inverting
# the invariant the reference documents. Inert on modern git, so it needs
# pre-2.31-shaped output to be exercised at all.
real_git = shutil.which("git")
assert real_git, "git must be on PATH"
repo, base = _merge_repo(tmp_path)
overlay = _fake_git(
tmp_path,
"#!/bin/sh\n"
'for a in "$@"; do\n'
' if [ "$a" = "--min-parents=2" ]; then\n'
" printf 'aaaa\\037p1 p2\\037merge story 1-3\\n\\n2\\t1\\ts.py\\n"
"aaaa\\037p1 p2\\037merge story 1-3\\n\\n5\\t4\\ts.py\\n'\n"
" exit 0\n"
" fi\n"
"done\n"
f'exec "{real_git}" "$@"\n',
)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD", env=overlay))
# One merge sha, however many blocks git printed for it.
assert out["merges_measured"] == 1
assert out["merges_measured"] <= out["merge_count"]
merge_files = {f["path"]: f for f in out["merge_files"]}
# The first block is the first-parent diff on every git version; the
# repeat must not be added on top of it.
assert merge_files["s.py"]["added"] == 2
assert merge_files["s.py"]["deleted"] == 1
assert merge_files["s.py"]["commit_count"] == 1
def test_valid_range_with_no_commits_keeps_the_full_shape(tmp_path):
# A mis-specified epic range is a valid, empty range. It must still answer
# with every documented key rather than a differently-shaped stub.
repo = _make_repo(tmp_path)
proc = _proc("--repo", str(repo), "--range", "HEAD..HEAD")
out = _json(proc)
assert proc.returncode == 0
assert set(out) == {
"range",
"commit_count",
"merge_count",
"merges_measured",
"commits",
"files",
"merge_files",
"stories_supplied",
}
assert out["commit_count"] == 0
assert out["merge_count"] == 0
assert out["merges_measured"] == 0
assert out["commits"] == [] and out["files"] == [] and out["merge_files"] == []
def test_linear_history_reports_no_merges(tmp_path):
repo = _make_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
assert out["merge_count"] == 0
assert out["merges_measured"] == 0
assert out["merge_files"] == []
def _recording_git(tmp_path, calls):
"""A `git` shim that records each invocation as one \\x1f-delimited record
(one field per argument, so argument boundaries survive) and then execs the
real git."""
real_git = shutil.which("git")
assert real_git, "git must be on PATH"
return _fake_git(
tmp_path,
"#!/bin/sh\n"
f"( printf '%s\\037' \"$@\"; printf '\\n' ) >> \"{calls}\"\n"
f'exec "{real_git}" "$@"\n',
)
def _numstat_invocations(calls):
"""The recorded `git log --numstat` invocations, each as an argument list."""
out = []
for line in calls.read_text().splitlines():
argv = [field for field in line.split("\x1f") if field]
if "--numstat" in argv:
out.append(argv)
return out
def test_second_pass_runs_only_when_the_range_has_merges(tmp_path):
# The merge pass is skipped outright on linear history, so the common case
# still costs exactly one `git log` — and the two passes must differ in
# exactly the arguments the design depends on.
calls = tmp_path / "calls.log"
overlay = _recording_git(tmp_path, calls)
merge_args = {"-m", "--first-parent", "--min-parents=2"}
(tmp_path / "linear").mkdir()
(tmp_path / "merged").mkdir()
linear = _make_repo(tmp_path / "linear")
_json(_proc("--repo", str(linear), "--range", "HEAD~1..HEAD", env=overlay))
logs = _numstat_invocations(calls)
assert len(logs) == 1, logs
calls.write_text("")
merged, base = _merge_repo(tmp_path / "merged")
_json(_proc("--repo", str(merged), "--range", f"{base}..HEAD", env=overlay))
logs = _numstat_invocations(calls)
assert len(logs) == 2, logs
# Pass 1 keeps full topology: none of the merge-pass arguments may reach it,
# or the story-branch commits drop out of the listing and attribution dies.
assert merge_args.isdisjoint(logs[0]), logs[0]
assert merge_args.issubset(logs[1]), logs[1]
def test_multi_story_subject_attributes_to_every_match(tmp_path):
# First-match-wins silently dropped the second story from the attribution.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "commit", "-qam", "fix seam between 1-2 and 1-3")
out = _json(
_proc(
"--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2"
)
)
# Every match, in --stories order — not whichever id was passed first.
assert out["commits"][0]["stories"] == ["1-3", "1-2"]
# A repeated id must not list the commit twice: any per-story total built
# from `stories` would count it twice.
repeated = _json(
_proc(
"--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2,1-3"
)
)
assert repeated["commits"][0]["stories"] == ["1-3", "1-2"]
assert repeated["stories_supplied"] == ["1-3", "1-2"]
def test_subject_naming_no_story_gets_an_empty_list(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "commit", "-qam", "chore: tidy imports")
out = _json(
_proc(
"--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-3"
)
)
assert out["commits"][0]["stories"] == []
def test_git_failure_with_empty_stderr_reports_the_exit_code(tmp_path):
# A quiet git failure (signal kill, empty stderr) must not leave the caller
# with `"error": ""` and nothing to report.
overlay = _fake_git(tmp_path, "#!/bin/sh\nexit 3\n")
proc = _proc("--repo", str(tmp_path), "--range", "HEAD~1..HEAD", env=overlay)
out = _json(proc)
assert proc.returncode == 1
assert out["ok"] is False
assert out["error"] == "git exited 3"
def _binary_repo(tmp_path):
"""A binary-only path plus a path that is binary twice and text once."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "keep.txt").write_text("keep\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "src").mkdir()
(repo / "src" / "x.py").write_bytes(b"\x00\x01bin\n")
(repo / "blob.bin").write_bytes(b"\x00\x01blob\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "add binary content")
(repo / "src" / "x.py").write_text("one\ntwo\n") # binary -> text: still binary
_git(repo, "commit", "-qam", "x.py becomes text")
(repo / "src" / "x.py").write_text("one\ntwo\nthree\n") # text -> text: measured
_git(repo, "commit", "-qam", "grow x.py")
(repo / "blob.bin").write_bytes(b"\x00\x02blob\n")
_git(repo, "commit", "-qam", "churn the blob")
return repo, base
def test_binary_revisions_no_longer_erase_measured_text_churn(tmp_path):
repo, base = _binary_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
x = {f["path"]: f for f in out["files"]}["src/x.py"]
assert x["added"] == 1 and x["deleted"] == 0 and x["net"] == 1
assert x["binary_revisions"] == 2
assert x["commit_count"] == 3
def test_binary_only_path_reports_zero_sums_and_its_revision_count(tmp_path):
repo, base = _binary_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
blob = {f["path"]: f for f in out["files"]}["blob.bin"]
assert blob["added"] == 0 and blob["deleted"] == 0 and blob["net"] == 0
assert blob["binary_revisions"] == 2
assert blob["commit_count"] == 2
def test_success_shape_carries_every_documented_key(tmp_path):
repo = _make_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
assert set(out) >= {
"range",
"commit_count",
"merge_count",
"merges_measured",
"commits",
"files",
"merge_files",
"stories_supplied",
}
assert set(out["commits"][0]) == {"sha", "subject", "stories", "is_merge"}
assert set(out["files"][0]) == {
"path",
"added",
"deleted",
"net",
"commit_count",
"binary_revisions",
}
def test_explicit_repo_ignores_ambient_git_dir(tmp_path):
repo = _make_repo(tmp_path)
proc = _proc(
"--repo",
str(repo),
"--range",
"HEAD~1..HEAD",
env={"GIT_DIR": str(tmp_path / "wrong-git-dir")},
)
assert _json(proc)["commit_count"] == 1
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))