oc_chat
Loading...
Searching...
No Matches
history.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/reconcile/history.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_reconcile_history.py"
6"""
7Git-history reader for the roadmap reconciliation check (Plan 205).
8
9Purpose
10-------
11Recover, per commit, the two facts the check joins on: which ``source/changelog``
12records the commit ADDED, and which roadmap record statuses the same commit MOVED.
13
14Responsibilities
15----------------
16- ``read_history`` — one ``git log`` pass for added changelog records, then a
17 status-map diff only for the commits that also touched ``phases``/``items``.
18- Recover nested ``sub_phases`` transitions **by id**, by loading the whole
19 record before and after the commit rather than parsing a unified diff (a
20 ``-U0`` hunk cannot reliably associate a changed ``status:`` with its ``id:``).
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - history read lifecycle
27 L3 - per-commit summaries
28 L4 - per-blob status-map details
29
30Contracts
31---------
32- Read-only: every git invocation is a query (``log`` / ``show``); the reader
33 never writes to the repository, the index, or the working tree.
34- All git output is decoded as UTF-8 with replacement — roadmap records carry
35 em-dashes and emoji, and the Windows console codepage must never corrupt them.
36- A record that cannot be parsed at some historical revision contributes no
37 status entries rather than raising: history predates the current schema.
38
39Dependencies
40------------
41- oc_diagnostics (structured debug logging via _dbg)
42- oc_chat.registry.source.canonical (frontmatter splitting)
43- yaml (historical frontmatter parsing)
44"""
45from __future__ import annotations
46
47import subprocess
48from dataclasses import dataclass, field
49from pathlib import Path
50from typing import Mapping
51
52import yaml
53
54from oc_diagnostics import _dbg
55
56from oc_chat.registry.source.canonical import split_record_text
57
58_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
59
60#: Source-tree-relative subdirectories that carry status-bearing records.
61STATUS_DIRS: tuple[str, ...] = ("phases", "items")
62
63#: Source-tree-relative subdirectory that carries changelog records.
64CHANGELOG_DIR = "changelog"
65
66_RECORD_SEP = "\x01"
67_FIELD_SEP = "\x1f"
68
69
70class HistoryError(RuntimeError):
71 """Raised when the repository cannot be read (not when a record cannot parse)."""
72
73
74@dataclass(frozen=True)
76 """One roadmap record status movement inside a single commit."""
77
78 record_id: str
79 from_status: str | None
80 to_status: str
81 path: str
82
83
84@dataclass(frozen=True)
86 """One commit, projected to the two facts the reconciliation check joins on.
87
88 ``bodies`` carries each added record's prose **as it was written in this
89 commit**. The claim that matters is the one the round actually made; reading
90 today's text instead would judge a past commit by a later edit. It is
91 optional so the engine stays injectable without git.
92 """
93
94 sha: str
95 date: str
96 subject: str
97 added_changelog: tuple[str, ...]
98 status_changes: tuple[StatusChange, ...]
99 bodies: Mapping[str, str] = field(default_factory=dict)
100
101 @property
102 def moved_ids(self) -> frozenset[str]:
103 """The set of roadmap record ids whose status moved in this commit."""
104
105 return frozenset(change.record_id for change in self.status_changes)
106
107
108def _git(repo_root: Path, *args: str) -> str:
109 """Run a read-only git query in ``repo_root`` and return its UTF-8 stdout."""
110
111 try:
112 completed = subprocess.run(
113 ["git", *args],
114 cwd=str(repo_root),
115 capture_output=True,
116 text=True,
117 encoding="utf-8",
118 errors="replace",
119 check=True,
120 )
121 except FileNotFoundError as error: # git absent from PATH
122 raise HistoryError("git executable not found on PATH") from error
123 except subprocess.CalledProcessError as error:
124 raise HistoryError(
125 f"git {' '.join(args[:2])} failed in {repo_root}: {error.stderr.strip()}"
126 ) from error
127 return completed.stdout
128
129
130def _blob(repo_root: Path, revision: str, path: str) -> str | None:
131 """Return the blob at ``revision:path``, or None when it does not exist there."""
132
133 completed = subprocess.run(
134 ["git", "show", f"{revision}:{path}"],
135 cwd=str(repo_root),
136 capture_output=True,
137 text=True,
138 encoding="utf-8",
139 errors="replace",
140 check=False,
141 )
142 if completed.returncode != 0:
143 return None
144 return completed.stdout
145
146
147def status_map(text: str | None) -> dict[str, str]:
148 """Project one record's text to ``{record_id: status}`` (top level + sub-phases).
149
150 Returns an empty map for absent or unparseable text: a record that predates
151 the current schema contributes no evidence rather than failing the run.
152 """
153
154 if not text or not text.strip():
155 return {}
156 try:
157 frontmatter, _body = split_record_text(text)
158 data = yaml.safe_load(frontmatter) or {}
159 except Exception: # noqa: BLE001 - historical revisions may not parse
160 return {}
161 if not isinstance(data, dict) or not data.get("id"):
162 return {}
163 statuses = {str(data["id"]): str(data.get("status"))}
164 for sub in data.get("sub_phases") or []:
165 if isinstance(sub, dict) and sub.get("id"):
166 statuses[str(sub["id"])] = str(sub.get("status"))
167 return statuses
168
169
170def _changed_status_paths(repo_root: Path, sha: str, source_rel: str) -> list[str]:
171 """Paths under ``phases``/``items`` that ``sha`` touched."""
172
173 pathspecs = [f"{source_rel}/{name}" for name in STATUS_DIRS]
174 output = _git(repo_root, "show", "--format=", "--name-only", sha, "--", *pathspecs)
175 return [line.strip() for line in output.splitlines() if line.strip()]
176
177
178def _status_changes(repo_root: Path, sha: str, source_rel: str) -> tuple[StatusChange, ...]:
179 """Diff the before/after status maps of every status-bearing path in ``sha``."""
180
181 changes: list[StatusChange] = []
182 for path in _changed_status_paths(repo_root, sha, source_rel):
183 before = status_map(_blob(repo_root, f"{sha}^", path))
184 after = status_map(_blob(repo_root, sha, path))
185 for record_id, to_status in sorted(after.items()):
186 if before.get(record_id) == to_status:
187 continue
188 changes.append(
190 record_id=record_id,
191 from_status=before.get(record_id),
192 to_status=to_status,
193 path=path,
194 )
195 )
196 return tuple(changes)
197
198
200 repo_root: Path, sha: str, prefix: str, added: tuple[str, ...]
201) -> dict[str, str]:
202 """The prose of each record ``sha`` added, as written in that commit."""
203
204 bodies: dict[str, str] = {}
205 for rel in added:
206 text = _blob(repo_root, sha, prefix + rel)
207 if not text:
208 continue
209 try:
210 _frontmatter, body = split_record_text(text)
211 except Exception: # noqa: BLE001 - a historical record may not split
212 body = text
213 bodies[rel] = body
214 return bodies
215
216
218 repo_root: str | Path,
219 *,
220 source_rel: str = "oc_chat/oc_chat/roadmap/source",
221 since: str | None = None,
222) -> tuple[CommitRecord, ...]:
223 """Read the commits that added changelog records, newest first.
224
225 ``since`` is passed straight to ``git log --since`` (any git date spec).
226 Only commits that added at least one ``changelog/**`` record are returned:
227 those are the commits that make a claim, and the check has nothing to say
228 about the others.
229 """
230
231 func = "read_history"
232 root = Path(repo_root)
233 args = [
234 "log",
235 "--diff-filter=A",
236 "--name-only",
237 f"--format={_RECORD_SEP}%H{_FIELD_SEP}%ad{_FIELD_SEP}%s",
238 "--date=short",
239 ]
240 if since:
241 args.append(f"--since={since}")
242 args += ["--", f"{source_rel}/{CHANGELOG_DIR}"]
243 output = _git(root, *args)
244
245 commits: list[CommitRecord] = []
246 prefix = f"{source_rel}/"
247 for block in output.split(_RECORD_SEP):
248 if not block.strip():
249 continue
250 head, _, rest = block.partition("\n")
251 parts = head.split(_FIELD_SEP)
252 if len(parts) != 3:
253 continue
254 sha, date, subject = parts
255 added = tuple(
256 line.strip()[len(prefix):]
257 for line in rest.splitlines()
258 if line.strip().startswith(prefix)
259 )
260 if not added:
261 continue
262 commits.append(
264 sha=sha,
265 date=date,
266 subject=subject,
267 added_changelog=added,
268 status_changes=_status_changes(root, sha, source_rel),
269 bodies=_added_bodies(root, sha, prefix, added),
270 )
271 )
272 _dbg("OC_CHAT.MAIN", func, f"read {len(commits)} claim-bearing commits", 2)
273 return tuple(commits)
str|None _blob(Path repo_root, str revision, str path)
Definition history.py:130
dict[str, str] status_map(str|None text)
Definition history.py:147
dict[str, str] _added_bodies(Path repo_root, str sha, str prefix, tuple[str,...] added)
Definition history.py:201
str _git(Path repo_root, *str args)
Definition history.py:108
tuple[StatusChange,...] _status_changes(Path repo_root, str sha, str source_rel)
Definition history.py:178
list[str] _changed_status_paths(Path repo_root, str sha, str source_rel)
Definition history.py:170
tuple[CommitRecord,...] read_history(str|Path repo_root, *, str source_rel="oc_chat/oc_chat/roadmap/source", str|None since=None)
Definition history.py:222