bmad 6.11

This commit is contained in:
2026-08-14 17:08:24 +02:00
parent 0edaef0be7
commit 373857c868
268 changed files with 26063 additions and 0 deletions
@@ -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()
@@ -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