bmad 6.11
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,304 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Measure git commit and file-change evidence over a revision range.
|
||||
|
||||
Prints ONLY JSON to stdout. Errors are emitted as JSON to stdout with a
|
||||
non-zero exit code: 2 for invalid arguments (rejected before git runs),
|
||||
1 for git or I/O failures. This script only MEASURES — it never judges
|
||||
acceleration or violations. The model interprets the numbers.
|
||||
|
||||
Two git passes. The first lists every commit in the range (merges included)
|
||||
and sums the per-file churn of the non-merge commits, which is what `files`
|
||||
reports. The second runs only when the range contains merges and measures
|
||||
those merges alone, reported separately as `merge_files` — never folded into
|
||||
`files`, because a merge's diff against its first parent restates the churn
|
||||
of the commits it merged in, which the first pass already counted.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
UNIT_SEP = "\x1f"
|
||||
# sha, space-separated parents (empty for a root commit), subject.
|
||||
LOG_FORMAT = f"--format=%H{UNIT_SEP}%P{UNIT_SEP}%s"
|
||||
|
||||
|
||||
def _emit(obj, code=0):
|
||||
sys.stdout.write(json.dumps(obj))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
class JsonArgumentParser(argparse.ArgumentParser):
|
||||
"""Emit argparse failures on the JSON-only stdout contract, not usage text.
|
||||
|
||||
The parser is constructed with ``add_help=False``. The override below covers
|
||||
``error()``, but ``-h`` never reaches it: the built-in help action calls
|
||||
``print_help()`` and ``exit(0)`` directly, which would put plain usage text
|
||||
on stdout with a zero exit and break the JSON-only contract. Removing the
|
||||
action instead of intercepting it routes ``-h`` through the already-tested
|
||||
``error()`` path as an ordinary unrecognized argument. The cost is that the
|
||||
``help=`` strings are unreachable from the CLI; the skill's references carry
|
||||
the usage a human needs.
|
||||
"""
|
||||
|
||||
def error(self, message):
|
||||
_emit({"ok": False, "error": f"argument error: {message}"}, 2)
|
||||
|
||||
|
||||
def _parse_numstat_line(line):
|
||||
# numstat lines: "<added>\t<deleted>\t<path>"; binary files use "-".
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
added_raw, deleted_raw, path = parts[0], parts[1], "\t".join(parts[2:])
|
||||
added = None if added_raw == "-" else int(added_raw)
|
||||
deleted = None if deleted_raw == "-" else int(deleted_raw)
|
||||
return added, deleted, path
|
||||
|
||||
|
||||
def _git_log(repo, extra_args, rng):
|
||||
"""Run one `git log --numstat` pass over `rng` and return its stdout.
|
||||
|
||||
`core.quotePath=false` keeps non-ASCII paths as real UTF-8 strings instead
|
||||
of octal escapes, and `--no-renames` makes a rename an honest delete + add
|
||||
instead of an unopenable "src/{a => b}" pseudo-path that splits one file's
|
||||
churn across several keys. Both matter for every pass, so both live here.
|
||||
|
||||
`log.diffMerges=separate` is pinned on the command line because it is what
|
||||
`-m` means: a user or repo config setting it to `off` makes pass 2 emit no
|
||||
file rows at all, so `merge_files` would come back empty beside a non-zero
|
||||
`merges_measured` and read as "the merges changed nothing".
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"-c",
|
||||
"core.quotePath=false",
|
||||
"-c",
|
||||
"log.diffMerges=separate",
|
||||
"-C",
|
||||
repo,
|
||||
"log",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
*extra_args,
|
||||
LOG_FORMAT,
|
||||
rng,
|
||||
"--", # terminate rev parsing so the range can never match a pathspec
|
||||
]
|
||||
try:
|
||||
# Decode explicitly: git emits UTF-8 path bytes regardless of the
|
||||
# caller's locale, and a C locale would otherwise decode them as ASCII.
|
||||
# surrogateescape, not replace: replace maps every invalid byte to the
|
||||
# same U+FFFD, so two distinct non-UTF-8 paths would collapse into one
|
||||
# `files` key with their churn silently summed. Lone surrogates survive
|
||||
# json.dumps (escaped as \udcXX under ensure_ascii) and json.loads.
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="surrogateescape",
|
||||
env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_emit({"ok": False, "error": str(exc)}, 1)
|
||||
|
||||
if proc.returncode != 0:
|
||||
# stderr can be empty (a signal kill, a quiet failure); the exit code is
|
||||
# then the only thing left to report, so never emit an empty error.
|
||||
_emit(
|
||||
{
|
||||
"ok": False,
|
||||
"error": proc.stderr.strip() or f"git exited {proc.returncode}",
|
||||
},
|
||||
1,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _parse_log(output, stories):
|
||||
"""Turn one pass's log output into (commits, files_map). Shared by both."""
|
||||
commits = []
|
||||
files = {} # path -> {path, _added, _deleted, binary_revisions, commit_count}
|
||||
seen = set()
|
||||
counting = True
|
||||
|
||||
for raw in output.splitlines():
|
||||
if UNIT_SEP in raw:
|
||||
sha, parents, subject = raw.split(UNIT_SEP, 2)
|
||||
# Under -m, git repeats a merge's header once per parent unless it
|
||||
# also honours --first-parent (git 2.31+). Count only the first
|
||||
# block for a sha — git emits parents in order, so that block is
|
||||
# the first-parent diff either way, and no churn is double counted.
|
||||
counting = sha not in seen
|
||||
if not counting:
|
||||
continue
|
||||
seen.add(sha)
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"subject": subject,
|
||||
# Every id the subject names, in --stories order: a commit
|
||||
# spanning two stories belongs to both. Word-boundary match
|
||||
# so a story id like "1-2" does not also match "11-2".
|
||||
"stories": [
|
||||
sid
|
||||
for sid in stories
|
||||
if re.search(rf"\b{re.escape(sid)}\b", subject)
|
||||
],
|
||||
"is_merge": len(parents.split()) > 1,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not counting or not raw.strip():
|
||||
continue
|
||||
|
||||
parsed = _parse_numstat_line(raw)
|
||||
if parsed is None:
|
||||
continue
|
||||
added, deleted, path = parsed
|
||||
|
||||
entry = files.get(path)
|
||||
if entry is None:
|
||||
# _added/_deleted are running sums over the path's text revisions.
|
||||
entry = {
|
||||
"path": path,
|
||||
"_added": 0,
|
||||
"_deleted": 0,
|
||||
"binary_revisions": 0,
|
||||
"commit_count": 0,
|
||||
}
|
||||
files[path] = entry
|
||||
|
||||
entry["commit_count"] += 1
|
||||
if added is None or deleted is None:
|
||||
# A binary revision is unmeasurable, not zero — count it alongside
|
||||
# the sums instead of nulling the path's real measured churn.
|
||||
entry["binary_revisions"] += 1
|
||||
else:
|
||||
entry["_added"] += added
|
||||
entry["_deleted"] += deleted
|
||||
|
||||
return commits, files
|
||||
|
||||
|
||||
def _file_list(files):
|
||||
return [
|
||||
{
|
||||
"path": entry["path"],
|
||||
"added": entry["_added"],
|
||||
"deleted": entry["_deleted"],
|
||||
"net": entry["_added"] - entry["_deleted"],
|
||||
"commit_count": entry["commit_count"],
|
||||
"binary_revisions": entry["binary_revisions"],
|
||||
}
|
||||
for entry in files.values()
|
||||
]
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = JsonArgumentParser(
|
||||
description=(
|
||||
"Measure commit and per-file change evidence over a git revision "
|
||||
"range. Measures only; does not judge."
|
||||
),
|
||||
add_help=False,
|
||||
)
|
||||
parser.add_argument("--repo", default=".", help="Path to the git repo (default: .)")
|
||||
parser.add_argument("--range", dest="range", help="Revision range REV..REV")
|
||||
parser.add_argument(
|
||||
"--stories",
|
||||
help="Comma-separated story ids to match against commit subjects.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
stories = []
|
||||
if args.stories:
|
||||
# dict.fromkeys dedupes while keeping the caller's order: a repeated id
|
||||
# would otherwise land twice in a commit's `stories`, double counting
|
||||
# that commit in any per-story total built from the output.
|
||||
stories = list(
|
||||
dict.fromkeys(s.strip() for s in args.stories.split(",") if s.strip())
|
||||
)
|
||||
|
||||
if not args.range:
|
||||
_emit(
|
||||
{
|
||||
"range": None,
|
||||
"note": "no range supplied",
|
||||
"commits": [],
|
||||
"files": [],
|
||||
}
|
||||
)
|
||||
|
||||
# Accept only an explicit REV..REV range. Anything else silently measures
|
||||
# the wrong thing: a leading "-" is consumed by git as an option, a single
|
||||
# rev logs all history up to it, a bare pathspec logs by path, an empty
|
||||
# endpoint ("..", "a..", "..b") makes git default that side to HEAD, and a
|
||||
# three-dot "A...B" is a symmetric difference — a different commit set
|
||||
# entirely. partition splits at the FIRST "..", so any of those extra-dot
|
||||
# shapes leaves `right` empty or dot-prefixed.
|
||||
left, _, right = args.range.partition("..")
|
||||
if (
|
||||
args.range != args.range.strip()
|
||||
or args.range.startswith("-")
|
||||
or not left
|
||||
or not right
|
||||
or right.startswith(".")
|
||||
):
|
||||
_emit(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"invalid --range {args.range!r}: expected a revision range like REV..REV",
|
||||
},
|
||||
2,
|
||||
)
|
||||
|
||||
# Pass 1 — the listing. No extra args, so full topology: every commit in
|
||||
# the range including merges, which is what per-story attribution reads.
|
||||
# Merges contribute no numstat rows here, so `files` is non-merge churn.
|
||||
commits, files = _parse_log(_git_log(args.repo, [], args.range), stories)
|
||||
merge_count = sum(1 for commit in commits if commit["is_merge"])
|
||||
|
||||
# Pass 2 — merge churn, only when there is any. `-m --first-parent
|
||||
# --min-parents=2` walks the range head's first-parent spine and emits
|
||||
# exactly one diff-against-first-parent block per merge sitting on it.
|
||||
# Merges off that spine are counted in merge_count and never measured,
|
||||
# which is precisely why merges_measured is a separate key: the gap
|
||||
# between the two is a visible statement that some merges went
|
||||
# unmeasured. This never folds into `files` — a merge's first-parent diff
|
||||
# restates the churn of the commits it merged in, which pass 1 already
|
||||
# counted, so adding it in would double count.
|
||||
merge_commits, merge_files = [], {}
|
||||
if merge_count:
|
||||
merge_commits, merge_files = _parse_log(
|
||||
_git_log(
|
||||
args.repo,
|
||||
["-m", "--first-parent", "--min-parents=2"],
|
||||
args.range,
|
||||
),
|
||||
stories,
|
||||
)
|
||||
|
||||
_emit(
|
||||
{
|
||||
"range": args.range,
|
||||
"commit_count": len(commits),
|
||||
"merge_count": merge_count,
|
||||
"merges_measured": len(merge_commits),
|
||||
"commits": commits,
|
||||
"files": _file_list(files),
|
||||
"merge_files": _file_list(merge_files),
|
||||
"stories_supplied": stories,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,746 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["ruamel.yaml>=0.18"]
|
||||
# ///
|
||||
"""Detect the current retrospective epic and surgically update sprint-status.yaml.
|
||||
|
||||
Prints ONLY JSON to stdout. Errors are emitted as JSON to stdout with a non-zero
|
||||
exit code. The ``update`` subcommand round-trips the YAML to preserve all comments
|
||||
and formatting, writes atomically (temp file + ``os.replace``), and restores the
|
||||
original file bytes on any validation failure.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
|
||||
|
||||
STORY_RE = re.compile(r"^(\d+)-\d+[a-z]?-") # trailing [a-z]? matches split-story keys like 2-6a-...
|
||||
DATE_FORMAT = "%m-%d-%Y %H:%M"
|
||||
# The authoritative action-item vocabulary, mirrored from bmad-sprint-planning's
|
||||
# SKILL.md. Anything outside it would render as unknown in the status dashboard.
|
||||
ACTION_STATUSES = ("open", "in-progress", "done")
|
||||
# The retro-document frontmatter vocabulary. --verdict is only echoed back, but
|
||||
# orchestrators branch on the echo, so a free-spelled value ("accepted with open
|
||||
# items") would silently fall through every branch they write.
|
||||
VERDICTS = ("accepted", "accepted-with-open-items", "rejected")
|
||||
|
||||
|
||||
def _load_yaml(path):
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
# Pin the emitter to the indentation the sprint-status template ships with.
|
||||
# Without this, ruamel re-dumps block sequences at its own default offset and
|
||||
# every write silently de-indents pre-existing, untouched action_items.
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
# Pin the dump encoding too: `_dump_bytes` serializes into a BytesIO, so the
|
||||
# emitter -- not this module -- encodes the bytes that land in the user's
|
||||
# file. utf-8 is ruamel's current default, but the file is read back as
|
||||
# utf-8 unconditionally, so state it rather than inherit it.
|
||||
yaml.encoding = "utf-8"
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = yaml.load(fh)
|
||||
return yaml, data
|
||||
|
||||
|
||||
def _emit(obj, code=0):
|
||||
sys.stdout.write(json.dumps(obj))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def _emit_error(message, code=1, restored=None):
|
||||
"""Emit a failure on the JSON-only contract.
|
||||
|
||||
``restored`` is included only when the caller can speak to the state of the
|
||||
target file; ``retro-document.md`` teaches callers to read it, so a write-path
|
||||
failure must never omit it and a read-only subcommand must never invent it.
|
||||
"""
|
||||
payload = {"ok": False, "error": message}
|
||||
if restored is not None:
|
||||
payload["restored"] = restored
|
||||
_emit(payload, code)
|
||||
|
||||
|
||||
class JsonArgumentParser(argparse.ArgumentParser):
|
||||
"""Emit argparse failures on the JSON-only stdout contract, not usage text.
|
||||
|
||||
Every parser built from this class is constructed with ``add_help=False``.
|
||||
The override below covers ``error()``, but ``-h`` never reaches it: the
|
||||
built-in help action calls ``print_help()`` and ``exit(0)`` directly, which
|
||||
would put plain usage text on stdout with a zero exit and break the
|
||||
JSON-only contract for the machine consumer this script exists to serve.
|
||||
Removing the action instead of intercepting it keeps the fix to one keyword
|
||||
per parser and routes ``-h`` through the already-tested ``error()`` path as
|
||||
an ordinary unrecognized argument. The cost is that the ``help=`` strings
|
||||
are unreachable from the CLI; the skill's references carry the usage a
|
||||
human needs.
|
||||
"""
|
||||
|
||||
def error(self, message):
|
||||
_emit({"ok": False, "error": f"argument error: {message}"}, 2)
|
||||
|
||||
|
||||
def _slugify(text, maxlen=40):
|
||||
text = str(text)
|
||||
# Unicode-aware: a non-Latin action must keep its own characters in the id
|
||||
# rather than collapsing to a single placeholder shared by every item.
|
||||
slug = re.sub(r"[^\w]+", "-", text.lower(), flags=re.UNICODE).strip("-")
|
||||
slug = slug[:maxlen].strip("-")
|
||||
if not slug:
|
||||
# Nothing sluggable (punctuation/emoji only): a short content hash keeps
|
||||
# the id deterministic and distinct instead of a bare "item".
|
||||
slug = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
||||
return slug
|
||||
|
||||
|
||||
def _selector_label(entry):
|
||||
"""Human-readable form of a --set-action-status selector, for error text.
|
||||
|
||||
An entry carrying both forms is described by its ``id``, because that is the
|
||||
form resolution actually uses.
|
||||
"""
|
||||
if isinstance(entry.get("id"), str):
|
||||
return f"id={entry['id']!r}"
|
||||
return f"epic={entry.get('epic')!r} action={entry.get('action')!r}"
|
||||
|
||||
|
||||
def _match_action_items(entry, items):
|
||||
"""Indices in ``items`` that the selector ``entry`` resolves to.
|
||||
|
||||
``id`` wins whenever it is present: the epic/action pair is the fallback for
|
||||
legacy items written before ids existed, so a caller that copied a whole item
|
||||
through gets the precise match rather than a text comparison. Matching is
|
||||
exact equality -- no normalization -- so a file that spells its epic as a
|
||||
string simply does not match and the caller gets a "no match" error instead of
|
||||
a silent write to the wrong item. ``bool`` is excluded on the file side too,
|
||||
since ``True == 1`` in Python.
|
||||
"""
|
||||
item_id = entry.get("id")
|
||||
if isinstance(item_id, str):
|
||||
return [
|
||||
idx
|
||||
for idx, item in enumerate(items)
|
||||
if isinstance(item, Mapping) and item.get("id") == item_id
|
||||
]
|
||||
epic_value = entry.get("epic")
|
||||
action_value = entry.get("action")
|
||||
return [
|
||||
idx
|
||||
for idx, item in enumerate(items)
|
||||
if isinstance(item, Mapping)
|
||||
and not isinstance(item.get("epic"), bool)
|
||||
and item.get("epic") == epic_value
|
||||
and item.get("action") == action_value
|
||||
]
|
||||
|
||||
|
||||
def _comment_counts(text):
|
||||
"""Multiset of the comment lines in ``text``, indentation included.
|
||||
|
||||
Keyed by the whole line so that a re-indented comment counts as a loss too:
|
||||
the guarantee callers are given is comments *and formatting*, and ruamel
|
||||
re-emits comments at their original column even when the block around them
|
||||
is re-indented, so an exact key costs nothing in practice.
|
||||
"""
|
||||
return Counter(
|
||||
line for line in text.splitlines() if line.lstrip().startswith("#")
|
||||
)
|
||||
|
||||
|
||||
def _load_document(path, restored=None):
|
||||
"""Load and shape-check the document, reporting every failure as JSON.
|
||||
|
||||
Returns ``(yaml, data, dev)``. ``dev`` is the live ``development_status``
|
||||
mapping when the key exists, otherwise a detached empty mapping -- the key is
|
||||
never inserted into the document as a side effect of loading.
|
||||
"""
|
||||
try:
|
||||
yaml, data = _load_yaml(path)
|
||||
except UnicodeDecodeError as exc:
|
||||
_emit_error(f"{path} is not valid UTF-8: {exc}", 1, restored)
|
||||
except OSError as exc:
|
||||
_emit_error(str(exc), 1, restored)
|
||||
except Exception as exc: # noqa: BLE001 - report any parse error as JSON
|
||||
_emit_error(str(exc), 1, restored)
|
||||
|
||||
if data is not None and not isinstance(data, Mapping):
|
||||
_emit_error("root document is not a mapping", 1, restored)
|
||||
|
||||
dev = data.get("development_status") if data is not None else None
|
||||
if dev is None:
|
||||
dev = {}
|
||||
elif not isinstance(dev, Mapping):
|
||||
_emit_error("development_status is not a mapping", 1, restored)
|
||||
|
||||
return yaml, data, dev
|
||||
|
||||
|
||||
def _retro_status(dev, retro_key, restored=None):
|
||||
status_value = dev.get(retro_key)
|
||||
if status_value is not None and not isinstance(status_value, str):
|
||||
_emit_error(
|
||||
f"{retro_key} status must be a string or null",
|
||||
1,
|
||||
restored,
|
||||
)
|
||||
return status_value
|
||||
|
||||
|
||||
def _dump_bytes(yaml, data):
|
||||
"""Serialize the document to bytes before any file is touched, so a dump
|
||||
failure cannot leave a partial file anywhere."""
|
||||
buf = io.BytesIO()
|
||||
yaml.dump(data, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _atomic_write(path, payload, mode=None):
|
||||
"""Replace ``path``'s contents with ``payload`` atomically.
|
||||
|
||||
The bytes land in a temp file alongside the target, are fsynced, take the
|
||||
target's permission bits (mkstemp creates 0600, which would silently narrow
|
||||
the file), and only then rename over it -- so a kill or a full disk leaves
|
||||
the original file intact rather than truncated. ``path`` is resolved through
|
||||
symlinks first: renaming onto a symlink would detach the link and leave the
|
||||
real file stale while reporting success. The directory is fsynced too --
|
||||
best-effort, see below -- so the rename survives a power loss and not just
|
||||
the bytes.
|
||||
"""
|
||||
path = os.path.realpath(path)
|
||||
directory = os.path.dirname(path) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=".sprint-status-", suffix=".tmp", dir=directory
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(payload)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
if mode is not None:
|
||||
os.chmod(tmp_path, mode)
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
# The directory sync sits outside the try because once os.replace has
|
||||
# returned, the new bytes ARE the file: a failure past that point must not
|
||||
# propagate as a write failure, or the caller would report the original
|
||||
# "restored" about a write that in fact landed. Skipping it only risks the
|
||||
# rename not surviving a hard power loss.
|
||||
try:
|
||||
dir_fd = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_detect_epic(args):
|
||||
# detect-epic never writes, so it reports no "restored" key.
|
||||
_, _, dev = _load_document(args.file)
|
||||
|
||||
done_stories = []
|
||||
max_epic = None
|
||||
# Every story key with its epic, in document order, so the pending list can
|
||||
# be scoped to whichever epic detection lands on without a second pass over
|
||||
# the mapping. Non-story keys (epic-2, epic-2-retrospective, ...) never enter
|
||||
# here, because STORY_RE does not match them.
|
||||
story_keys = []
|
||||
for key, value in dev.items():
|
||||
m = STORY_RE.match(str(key))
|
||||
if not m:
|
||||
continue
|
||||
epic_num = int(m.group(1))
|
||||
story_keys.append((epic_num, key, value))
|
||||
if value == "done":
|
||||
done_stories.append(key)
|
||||
if max_epic is None or epic_num > max_epic:
|
||||
max_epic = epic_num
|
||||
|
||||
# Optional --epic aims the gate at a supplied number (the -H <epic> path)
|
||||
# instead of auto-picking the highest epic with a done story. Without it,
|
||||
# behavior is unchanged: detect, then scope pending_stories to that epic.
|
||||
if args.epic is not None:
|
||||
if args.epic < 1:
|
||||
_emit_error(
|
||||
f"invalid --epic {args.epic} (expected a positive integer)",
|
||||
1,
|
||||
)
|
||||
selected = args.epic
|
||||
else:
|
||||
selected = max_epic
|
||||
|
||||
if selected is None:
|
||||
# Uniform shape: pending_stories is always present, even with no epic to
|
||||
# scope it to, so a caller can read it without branching on epic first.
|
||||
_emit(
|
||||
{
|
||||
"epic": None,
|
||||
"story_count": 0,
|
||||
"done_stories": done_stories,
|
||||
"pending_stories": [],
|
||||
"retro_key": None,
|
||||
"retro_status": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Scoped to the selected epic only -- deliberately unlike done_stories, which
|
||||
# spans the whole file. A pending story in some *other* epic is not this
|
||||
# retrospective's business.
|
||||
selected_keys = [
|
||||
(key, value) for epic_num, key, value in story_keys if epic_num == selected
|
||||
]
|
||||
pending_stories = [key for key, value in selected_keys if value != "done"]
|
||||
|
||||
retro_key = f"epic-{selected}-retrospective"
|
||||
retro_status = _retro_status(dev, retro_key)
|
||||
_emit(
|
||||
{
|
||||
"epic": selected,
|
||||
# An epic the file has never heard of returns the same empty
|
||||
# pending_stories as a finished one; story_count is the key that
|
||||
# separates "complete" from "nonexistent" (a typo'd --epic), so the
|
||||
# unfinished-story gate can refuse to read silence as done.
|
||||
"story_count": len(selected_keys),
|
||||
"done_stories": done_stories,
|
||||
"pending_stories": pending_stories,
|
||||
"retro_key": retro_key,
|
||||
"retro_status": retro_status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
# Every failure below happens before the write is attempted, so the file is
|
||||
# untouched and "restored": true is the honest report.
|
||||
untouched = True
|
||||
|
||||
# 0. Validate the inputs before anything is mutated or written.
|
||||
if args.epic < 1:
|
||||
_emit_error(
|
||||
f"invalid --epic {args.epic} (expected a positive integer)", 1, untouched
|
||||
)
|
||||
|
||||
if args.date is not None:
|
||||
try:
|
||||
parsed_date = datetime.strptime(args.date, DATE_FORMAT)
|
||||
except (ValueError, TypeError):
|
||||
_emit_error(
|
||||
f'invalid --date {args.date!r} (expected "MM-DD-YYYY HH:MM")',
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
# Normalize: strptime also accepts unpadded spellings like
|
||||
# "1-2-2026 9:05", and writing those through would defeat the point of
|
||||
# validating the format at all.
|
||||
last_updated = parsed_date.strftime(DATE_FORMAT)
|
||||
else:
|
||||
last_updated = datetime.now().strftime(DATE_FORMAT)
|
||||
|
||||
if args.verdict is not None and args.verdict not in VERDICTS:
|
||||
_emit_error(
|
||||
f"invalid --verdict {args.verdict!r} (allowed: {', '.join(VERDICTS)})",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
|
||||
actions = []
|
||||
if args.add_action:
|
||||
try:
|
||||
actions = json.loads(args.add_action)
|
||||
except json.JSONDecodeError as exc:
|
||||
_emit_error(f"invalid --add-action JSON: {exc}", 1, untouched)
|
||||
if not isinstance(actions, list):
|
||||
_emit_error("--add-action must be a JSON array", 1, untouched)
|
||||
for item in actions:
|
||||
if not isinstance(item, dict):
|
||||
_emit_error(
|
||||
"each --add-action item must be an object", 1, untouched
|
||||
)
|
||||
action_value = item.get("action")
|
||||
if not isinstance(action_value, str) or not action_value.strip():
|
||||
# A JSON null/number/object would otherwise be str()'d into a
|
||||
# literal "None"/"{...}" and written as a real action item.
|
||||
_emit_error(
|
||||
"each --add-action item must have a non-empty string action",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
|
||||
status_updates = []
|
||||
if args.set_action_status:
|
||||
try:
|
||||
status_updates = json.loads(args.set_action_status)
|
||||
except json.JSONDecodeError as exc:
|
||||
_emit_error(f"invalid --set-action-status JSON: {exc}", 1, untouched)
|
||||
if not isinstance(status_updates, list):
|
||||
_emit_error("--set-action-status must be a JSON array", 1, untouched)
|
||||
for entry in status_updates:
|
||||
if not isinstance(entry, dict):
|
||||
_emit_error(
|
||||
"each --set-action-status entry must be an object", 1, untouched
|
||||
)
|
||||
status_value = entry.get("status")
|
||||
if not isinstance(status_value, str) or status_value not in ACTION_STATUSES:
|
||||
_emit_error(
|
||||
f"invalid --set-action-status status {status_value!r} "
|
||||
f"(allowed: {', '.join(ACTION_STATUSES)})",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
item_id = entry.get("id")
|
||||
if item_id is not None:
|
||||
# Present but unusable is an input error, not a silent fallback to
|
||||
# the epic/action form -- the caller meant to select by id.
|
||||
if not isinstance(item_id, str) or not item_id.strip():
|
||||
_emit_error(
|
||||
"each --set-action-status id must be a non-empty string",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
continue
|
||||
epic_value = entry.get("epic")
|
||||
action_value = entry.get("action")
|
||||
if isinstance(epic_value, bool) or not isinstance(epic_value, int):
|
||||
_emit_error(
|
||||
"each --set-action-status entry must have a non-empty string id, "
|
||||
"or an integer epic and a non-empty string action",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
if not isinstance(action_value, str) or not action_value.strip():
|
||||
_emit_error(
|
||||
"each --set-action-status entry must have a non-empty string id, "
|
||||
"or an integer epic and a non-empty string action",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
|
||||
# 1. Keep original bytes for restore-on-failure, and the mode to write back
|
||||
# with -- taken from the open handle so an unlink mid-run cannot leave the
|
||||
# replacement silently narrowed to mkstemp's 0600.
|
||||
try:
|
||||
with open(args.file, "rb") as fh:
|
||||
original_bytes = fh.read()
|
||||
original_mode = stat.S_IMODE(os.fstat(fh.fileno()).st_mode)
|
||||
except OSError as exc:
|
||||
_emit_error(str(exc), 1, untouched)
|
||||
|
||||
try:
|
||||
original_text = original_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
_emit_error(f"{args.file} is not valid UTF-8: {exc}", 1, untouched)
|
||||
|
||||
# Every comment line in the file, not just the leading block: the template
|
||||
# ships one above action_items, and losing it corrupts the document just the
|
||||
# same as losing the header.
|
||||
original_comments = _comment_counts(original_text)
|
||||
|
||||
yaml, data, dev = _load_document(args.file, restored=untouched)
|
||||
|
||||
if data is None:
|
||||
_emit_error("empty or invalid YAML document", 1, untouched)
|
||||
|
||||
epic = args.epic
|
||||
retro_key = f"epic-{epic}-retrospective"
|
||||
|
||||
# null distinguishes "the flag was not passed" from "the key was absent",
|
||||
# which is the only case retro-document.md assigns "false" to.
|
||||
retro_key_found = None
|
||||
retro_status_before = None
|
||||
retro_status_after = None
|
||||
|
||||
# 2. Optionally set the retrospective status to done (only if key exists).
|
||||
if args.set_retro_done:
|
||||
retro_key_found = retro_key in dev
|
||||
if retro_key_found:
|
||||
retro_status_before = _retro_status(dev, retro_key, restored=untouched)
|
||||
dev[retro_key] = "done"
|
||||
retro_status_after = "done"
|
||||
|
||||
# 3. Take the action_items sequence as loaded and shape-check it once; both
|
||||
# of the steps below operate on this same list.
|
||||
existing_actions = data.get("action_items")
|
||||
if existing_actions is not None and not isinstance(existing_actions, list):
|
||||
# A hand-corrupted file must still fail on the JSON contract, not crash.
|
||||
_emit_error("action_items in file is not a list", 1, untouched)
|
||||
items_added = 0
|
||||
original_action_len = len(existing_actions) if existing_actions is not None else 0
|
||||
|
||||
# 4. Optionally transition the status of items already in the file. Selectors
|
||||
# resolve against action_items *as loaded* and strictly before the
|
||||
# --add-action append below, which is what makes an item appended in the
|
||||
# same invocation unaddressable in that run. Every selector is resolved
|
||||
# before any is applied, so a rejected batch never leaves a partial edit --
|
||||
# and since nothing has been written yet, the file is still untouched.
|
||||
status_targets = []
|
||||
if status_updates:
|
||||
pool = existing_actions if isinstance(existing_actions, list) else []
|
||||
claimed = {}
|
||||
for entry in status_updates:
|
||||
label = _selector_label(entry)
|
||||
matches = _match_action_items(entry, pool)
|
||||
if not matches:
|
||||
_emit_error(f"no action item matches {label}", 1, untouched)
|
||||
if len(matches) > 1:
|
||||
_emit_error(
|
||||
f"ambiguous --set-action-status selector {label}: "
|
||||
f"{len(matches)} matches",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
idx = matches[0]
|
||||
if idx in claimed:
|
||||
# Applying both would overcount action_items_updated, and a
|
||||
# conflicting pair would surface as a confusing post-write
|
||||
# validation failure instead of the input error it is.
|
||||
_emit_error(
|
||||
f"duplicate --set-action-status targets: {label} and "
|
||||
f"{claimed[idx]} resolve to the same action item",
|
||||
1,
|
||||
untouched,
|
||||
)
|
||||
claimed[idx] = label
|
||||
status_targets.append((idx, entry["status"]))
|
||||
|
||||
for idx, new_status in status_targets:
|
||||
# A plain assignment keeps the item's own scalar style: ruamel's
|
||||
# CommentedMap re-applies the existing key's style on overwrite, for
|
||||
# every ScalarString subclass. Pinned by the style tests.
|
||||
pool[idx]["status"] = new_status
|
||||
|
||||
# 5. Optionally append action items.
|
||||
if actions:
|
||||
seq = data.get("action_items")
|
||||
if seq is None:
|
||||
seq = []
|
||||
data["action_items"] = seq
|
||||
|
||||
for item in actions:
|
||||
# Stable identity for orchestrator consumers: an id that lets a
|
||||
# re-run dedupe against prior items, and a ref back to the sourced
|
||||
# finding in the retro document. Both accept an explicit override.
|
||||
seq_num = len(seq) + 1
|
||||
action_text = str(item.get("action", ""))
|
||||
item_id = item.get("id") or (
|
||||
f"epic-{int(epic)}-retro-item-{seq_num}-{_slugify(action_text)}"
|
||||
)
|
||||
ref = item.get("ref") or (args.ref or "")
|
||||
entry = {
|
||||
"id": DoubleQuotedScalarString(str(item_id)),
|
||||
"epic": int(epic),
|
||||
"action": DoubleQuotedScalarString(action_text),
|
||||
"owner": DoubleQuotedScalarString(str(item.get("owner", ""))),
|
||||
"status": "open",
|
||||
"ref": DoubleQuotedScalarString(str(ref)),
|
||||
}
|
||||
seq.append(entry)
|
||||
items_added += 1
|
||||
|
||||
# 6. Update last_updated.
|
||||
data["last_updated"] = last_updated
|
||||
|
||||
# 7. Serialize, then swap the file atomically.
|
||||
try:
|
||||
_atomic_write(args.file, _dump_bytes(yaml, data), original_mode)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# The target is only ever touched by the final rename, so if the write
|
||||
# raised, the original is still on disk byte-for-byte. Calling _restore
|
||||
# here would rewrite a file that was never modified -- the one write in
|
||||
# the program with nothing to gain and a truncated file to lose.
|
||||
_emit({"ok": False, "error": f"write failed: {exc}", "restored": True}, 1)
|
||||
|
||||
# 8. Validate the written file; restore on any failure.
|
||||
def _fail(msg):
|
||||
restored = _restore(args.file, original_bytes, original_mode)
|
||||
_emit({"ok": False, "error": msg, "restored": restored}, 1)
|
||||
|
||||
try:
|
||||
_, reloaded = _load_yaml(args.file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_fail(f"re-parse failed after write: {exc}")
|
||||
|
||||
if reloaded is None:
|
||||
_fail("re-parse produced empty document after write")
|
||||
|
||||
if not isinstance(reloaded, Mapping):
|
||||
_fail("re-parse produced a non-mapping document after write")
|
||||
|
||||
rdev = reloaded.get("development_status") or {}
|
||||
if args.set_retro_done and retro_key_found:
|
||||
if not isinstance(rdev, Mapping) or rdev.get(retro_key) != "done":
|
||||
_fail(f"validation: {retro_key} not set to done after write")
|
||||
|
||||
new_action_len = 0
|
||||
if reloaded.get("action_items") is not None:
|
||||
new_action_len = len(reloaded.get("action_items"))
|
||||
if new_action_len != original_action_len + items_added:
|
||||
_fail(
|
||||
"validation: action_items length mismatch "
|
||||
f"(expected {original_action_len + items_added}, got {new_action_len})"
|
||||
)
|
||||
|
||||
if status_targets:
|
||||
# The recorded indices are still valid: the only other mutation to the
|
||||
# sequence is an append, and the length check above just confirmed it.
|
||||
reloaded_actions = reloaded.get("action_items")
|
||||
if not isinstance(reloaded_actions, list):
|
||||
_fail("validation: action_items is not a list after write")
|
||||
for idx, new_status in status_targets:
|
||||
reloaded_item = reloaded_actions[idx]
|
||||
if (
|
||||
not isinstance(reloaded_item, Mapping)
|
||||
or reloaded_item.get("status") != new_status
|
||||
):
|
||||
_fail(
|
||||
f"validation: action item at index {idx} is not "
|
||||
f"{new_status!r} after write"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(args.file, "r", encoding="utf-8") as fh:
|
||||
new_text = fh.read()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
_fail(f"re-read failed after write: {exc}")
|
||||
|
||||
# Loss-only: a comment may legitimately move or be added (a long quoted value
|
||||
# can wrap onto a line that begins with '#'), but none may disappear.
|
||||
lost = original_comments - _comment_counts(new_text)
|
||||
if lost:
|
||||
first = next(
|
||||
(line for line in original_text.splitlines() if line in lost), None
|
||||
)
|
||||
_fail(f"validation: comment line lost after write: {first!r}")
|
||||
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"retro_key_found": retro_key_found,
|
||||
"retro_status_before": retro_status_before,
|
||||
"retro_status_after": retro_status_after,
|
||||
"action_items_added": items_added,
|
||||
"action_items_updated": len(status_targets),
|
||||
"last_updated": last_updated,
|
||||
"verdict": args.verdict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _restore(path, original_bytes, mode=None):
|
||||
"""Best-effort restore of the original bytes. Returns True on success so a
|
||||
caller can surface a restore failure instead of hiding a half-written file.
|
||||
|
||||
Atomic for the same reason the primary write is: a truncating rewrite that
|
||||
dies halfway destroys the very bytes it was trying to put back.
|
||||
"""
|
||||
try:
|
||||
_atomic_write(path, original_bytes, mode)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001 - best-effort restore
|
||||
sys.stderr.write(f"restore failed: {exc}\n")
|
||||
return False
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = JsonArgumentParser(
|
||||
description=(
|
||||
"Detect the current retrospective epic and surgically update "
|
||||
"sprint-status.yaml while preserving comments and formatting."
|
||||
),
|
||||
add_help=False,
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_detect = sub.add_parser(
|
||||
"detect-epic",
|
||||
help=(
|
||||
"Find the highest epic with a done story and its retrospective "
|
||||
"status, or aim the same pending_stories gate at --epic N."
|
||||
),
|
||||
add_help=False,
|
||||
)
|
||||
p_detect.add_argument("--file", required=True, help="Path to sprint-status.yaml")
|
||||
p_detect.add_argument(
|
||||
"--epic",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"Optional. Scope the response to this epic number instead of "
|
||||
"auto-detecting the highest epic with a done story. Orchestrators "
|
||||
"passing -H <epic> should pass the same number here so pending_stories "
|
||||
"covers the epic they are about to retro."
|
||||
),
|
||||
)
|
||||
p_detect.set_defaults(func=cmd_detect_epic)
|
||||
|
||||
p_update = sub.add_parser(
|
||||
"update",
|
||||
help="Surgically update retro status and/or action items.",
|
||||
add_help=False,
|
||||
)
|
||||
p_update.add_argument("--file", required=True, help="Path to sprint-status.yaml")
|
||||
p_update.add_argument("--epic", required=True, type=int, help="Epic number")
|
||||
p_update.add_argument(
|
||||
"--set-retro-done",
|
||||
action="store_true",
|
||||
help="Set epic-<N>-retrospective to done if the key exists.",
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--add-action",
|
||||
help='JSON array of {"action":str,"owner":str,"id"?:str,"ref"?:str} to append.',
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--set-action-status",
|
||||
help=(
|
||||
"JSON array of status transitions for action items already in the file. "
|
||||
'Select each by id -- {"id":str,"status":"open|in-progress|done"} -- or, '
|
||||
'for legacy items with no id, by epic plus exact action text: '
|
||||
'{"epic":int,"action":str,"status":...}. An entry carrying both uses the '
|
||||
"id. Every selector must match exactly one item; any failure aborts the "
|
||||
"whole invocation and leaves the file untouched."
|
||||
),
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--ref",
|
||||
help="Reference (e.g. the retro document path) recorded on each appended action item.",
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--verdict",
|
||||
help=(
|
||||
"Acceptance verdict echoed back in the JSON result for orchestrator "
|
||||
f"consumers. One of: {', '.join(VERDICTS)}."
|
||||
),
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--date",
|
||||
help='Value for last_updated (default: now as "MM-DD-YYYY HH:MM").',
|
||||
)
|
||||
p_update.set_defaults(func=cmd_update)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+71
@@ -0,0 +1,71 @@
|
||||
# Sprint Status Template
|
||||
# This is an EXAMPLE showing the expected format
|
||||
# The actual file will be generated with all epics/stories from your epic files
|
||||
|
||||
# generated: {date}
|
||||
# project: {project_name}
|
||||
# project_key: {project_key}
|
||||
# tracking_system: {tracking_system}
|
||||
# story_location: {story_location}
|
||||
|
||||
# STATUS DEFINITIONS:
|
||||
# ==================
|
||||
# Epic Status:
|
||||
# - backlog: Epic not yet started
|
||||
# - in-progress: Epic actively being worked on
|
||||
# - done: All stories in epic completed
|
||||
#
|
||||
# Story Status:
|
||||
# - backlog: Story only exists in epic file
|
||||
# - ready-for-dev: Story file created, ready for development
|
||||
# - in-progress: Developer actively working on implementation
|
||||
# - review: Implementation complete, ready for review
|
||||
# - done: Story completed
|
||||
#
|
||||
# Retrospective Status:
|
||||
# - optional: Can be completed but not required
|
||||
# - done: Retrospective has been completed
|
||||
#
|
||||
# Action Item Status:
|
||||
# - open: Committed during a retrospective, not yet addressed
|
||||
# - in-progress: Actively being worked on
|
||||
# - done: Completed
|
||||
#
|
||||
# WORKFLOW NOTES:
|
||||
# ===============
|
||||
# - Epic transitions to 'in-progress' automatically when its first story starts (via build's sprint sync)
|
||||
# - Stories can be worked in parallel if team capacity allows
|
||||
# - Developer typically creates the next story after the previous one is 'done' to incorporate learnings
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
# - Retrospective appends its action items to action_items; the status view surfaces open ones
|
||||
|
||||
# EXAMPLE STRUCTURE (your actual epics/stories will replace these):
|
||||
# Timestamps use MM-DD-YYYY HH:MM.
|
||||
|
||||
generated: 05-06-2025 21:30
|
||||
last_updated: 05-06-2025 21:30
|
||||
project: My Awesome Project
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
story_location: "docs/stories"
|
||||
|
||||
development_status:
|
||||
epic-1: backlog
|
||||
1-1-user-authentication: done
|
||||
1-2-account-management: ready-for-dev
|
||||
1-3-plant-data-model: backlog
|
||||
1-4-add-plant-manual: backlog
|
||||
epic-1-retrospective: optional
|
||||
|
||||
epic-2: backlog
|
||||
2-1-personality-system: backlog
|
||||
2-2-chat-interface: backlog
|
||||
2-3-llm-integration: backlog
|
||||
epic-2-retrospective: optional
|
||||
|
||||
# Action items committed during retrospectives (section created by the retrospective workflow)
|
||||
action_items:
|
||||
- epic: 1
|
||||
action: "Add error-handling review to the code review checklist"
|
||||
owner: "Charlie"
|
||||
status: open
|
||||
@@ -0,0 +1,750 @@
|
||||
# /// 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"]))
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user