oct
Loading...
Searching...
No Matches
commit_observer.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/commit_observer.py
4
5"""
6Purpose
7-------
8Detect silent mutations of tracked-but-unstaged files during the
9``oct git commit`` pipeline (B1.0). Snapshots the working-tree state
10of every tracked file outside the staged set at the start of the
11commit, then compares again right before and right after the
12underlying ``git commit`` invocation. Any byte-level change is
13reported as either a warning or a hard abort, depending on the
14``git.observe_unstaged_mutation`` octrc setting.
15
16Two detection methods produce those snapshots (plan 242). They agree
17on every verdict the control exists to produce; they differ only in
18what they read to reach it.
19
20``git`` (:data:`METHOD_GIT`)
21 Ask git which tracked files differ from the index
22 (``git diff --name-only -z``, answered from git's stat cache with
23 the racy-git re-hash every ``git commit`` already trusts) and
24 content-hash only those, plus the paths named in *also_hash*.
25 Every other tracked file carries :data:`_CLEAN_SENTINEL`. About
26 0.2 s per pass on a 13k-file tree.
27
28``hash`` (:data:`METHOD_HASH`)
29 SHA-256 every tracked-but-unstaged file. Byte-identical to the
30 pre-242 behaviour apart from the two enumeration fixes below.
31 188 s per pass on the same tree with the OS file cache cold.
32
33The commit flow resolves the method from the octrc key
34``git.observe_unstaged_mutation_method`` and always passes it
35explicitly; ``git`` is the default when the key is absent. This
36function's own parameter defaults to ``hash``, the more thorough of
37the two, so that a caller who names no method never silently gets
38less observation than it did before plan 242.
39
40Responsibilities
41----------------
42- Provide :func:`snapshot_unstaged_tracked` - returns a mapping of
43 repo-relative path -> sha256 hex digest or sentinel for every
44 tracked file *not* in the staged set.
45- Provide :func:`diff_snapshots` - returns the list of paths whose
46 digest changed (new file, deleted file, or content drift) between
47 two snapshots. Unchanged by plan 242; both methods feed it.
48- Be defensive: large files (> :data:`_MAX_HASH_BYTES`) are recorded
49 with the sentinel digest ``"OVERSIZE"`` so they don't dominate
50 commit latency, and a missing/unreadable file maps to ``""`` so the
51 diff path treats it consistently.
52- Enumerate the index with ``git ls-files -z --stage``, which fixes
53 two observation blind spots that were silent before plan 242:
54
55 1. Without ``-z``, ``git ls-files`` quotes and octal-escapes any
56 name containing a non-ASCII character (``core.quotepath``
57 defaults to true). The observer stat-failed on the quoted
58 string, recorded the missing sentinel, and never observed the
59 file - a mutation of it was invisible to the control.
60 2. Gitlink entries (mode ``160000``, a nested repository) came out
61 of ``git ls-files`` looking like files. The observer tried to
62 open the directory and logged a permission-denied L1 override on
63 every snapshot. They are now skipped by mode, not by luck.
64
65Diagnostics
66-----------
67Domain: GIT
68Levels:
69 L1 - errors: file IO failures during snapshot, unparsable index
70 records, path escapes
71 L2 - lifecycle: snapshot start / snapshot complete
72 L3 - details: file count, hashed count, gitlinks skipped
73 L4 - deep trace: per-file hash + size
74
75Contracts
76---------
77- This module is pure: no Click, no subprocess of its own. It uses
78 :func:`oct.core.git.git_run` to enumerate tracked files and, under
79 :data:`METHOD_GIT`, to ask for the dirty set.
80- Snapshot calls must never raise on individual-file IO errors -
81 they record the file as unreadable and continue. A failure of git
82 *itself* does propagate: the caller's fail-soft wrapper turns the
83 observer off for that commit rather than silently switching method,
84 which would reintroduce the cost this module exists to remove.
85- An unknown *method* is a programming error and raises
86 :class:`ValueError`; there is no permissive fallback in either
87 direction.
88- The hash digest is content-only (no metadata). Renames are detected
89 via the path key, not the digest.
90- A tracked path that resolves outside *repo_root* is dropped from
91 the snapshot rather than hashed, under both methods. Plan 242
92 computes that predicate once per distinct parent directory instead
93 of once per file (:func:`_make_escape_check`) - the same answer,
94 and the difference between a git-native pass costing 0.2 s and one
95 costing 2.4 s on a 13k-entry index.
96- Residual of :data:`METHOD_GIT`, accepted when the method was ruled
97 (FACTORY-01 D3 option (a)): a writer that restores a file's size
98 and mtime after writing evades git's stat cache where a SHA-256
99 walk would not, and a change visible only through a content filter
100 (``core.autocrlf``, a ``.gitattributes`` clean filter) is likewise
101 not a difference in git's eyes. Such a writer is adversarial, and
102 an adversarial writer can already disable the observer through
103 octrc, so it was never in this control's threat model. Run under
104 :data:`METHOD_HASH` for a paranoid pass.
105- One documented verdict difference between the methods: an oversize
106 file that is CLEAN at the pre snapshot is recorded as
107 :data:`_CLEAN_SENTINEL` under ``git`` and as
108 :data:`_OVERSIZE_SENTINEL` under ``hash``, so a mutation of it is
109 flagged under ``git`` and not under ``hash`` (``diff_snapshots``
110 cannot compare an OVERSIZE pair). ``git`` is strictly stronger;
111 ``hash`` keeps the pre-242 answer by design.
112
113Dependencies
114------------
115- oct.core.diagnostics (_dbg structured logger)
116- oct.core.git (lazy; git_run - the only subprocess path out of this module)
117- hashlib (SHA-256 content digests of tracked files)
118- pathlib (Path for the repo root and tracked-file paths)
119"""
120
121from __future__ import annotations
122
123import hashlib
124from pathlib import Path
125
126from oct.core.diagnostics import _dbg
127
128
129#: Files larger than this are recorded with sentinel ``"OVERSIZE"``
130#: rather than hashed. Keeps the snapshot cost bounded on repos with
131#: large generated artefacts checked into git.
132_MAX_HASH_BYTES: int = 10 * 1024 * 1024 # 10 MB
133
134#: Sentinel digest used when a file exceeds :data:`_MAX_HASH_BYTES`.
135_OVERSIZE_SENTINEL: str = "OVERSIZE"
136
137#: Sentinel digest used when a file is missing or unreadable. Empty
138#: string is distinct from any valid sha256 hex digest.
139_MISSING_SENTINEL: str = ""
140
141#: Sentinel recorded under :data:`METHOD_GIT` for a tracked file whose
142#: working tree matches the index, i.e. one git itself calls clean.
143#: Distinct from any sha256 hex digest and from the other sentinels.
144_CLEAN_SENTINEL: str = "CLEAN"
145
146#: Git-native detection: hash only what git reports as differing from
147#: the index. The commit flow's default.
148METHOD_GIT: str = "git"
149
150#: Content-walk detection: SHA-256 every tracked-but-unstaged file.
151METHOD_HASH: str = "hash"
152
153#: Every accepted value of ``git.observe_unstaged_mutation_method``.
154#: The commit flow validates against this tuple and refuses anything
155#: else by name rather than falling back in either direction.
156OBSERVE_METHODS: tuple[str, ...] = (METHOD_GIT, METHOD_HASH)
157
158#: Index mode of a gitlink (nested repository / submodule) entry.
159#: Such an entry is a directory on disk, never a blob, and is skipped.
160_GITLINK_MODE: str = "160000"
161
162#: Index mode of a symlink entry. The only entry kind whose own leaf
163#: can resolve outside the repository, so the only one that needs a
164#: per-entry resolve in :func:`_make_escape_check`.
165_SYMLINK_MODE: str = "120000"
166
167
168def _hash_file(path: Path) -> str:
169 """Return the sha256 hex digest of *path*, or a sentinel.
170
171 Returns :data:`_OVERSIZE_SENTINEL` when the file is larger than
172 :data:`_MAX_HASH_BYTES`, and :data:`_MISSING_SENTINEL` when the
173 file is missing or unreadable. Never raises.
174 """
175 func = "_hash_file"
176 try:
177 size = path.stat().st_size
178 except OSError as exc:
179 _dbg("GIT", func, f"stat failed: {path} ({exc})", 1, override=True)
180 return _MISSING_SENTINEL
181
182 if size > _MAX_HASH_BYTES:
183 _dbg("GIT", func, f"oversize: {path} ({size} bytes)", 4)
184 return _OVERSIZE_SENTINEL
185
186 try:
187 h = hashlib.sha256()
188 with path.open("rb") as fh:
189 while True:
190 chunk = fh.read(65536)
191 if not chunk:
192 break
193 h.update(chunk)
194 digest = h.hexdigest()
195 _dbg("GIT", func, f"hashed {path} -> {digest[:8]}…", 4)
196 return digest
197 except OSError as exc:
198 _dbg("GIT", func, f"read failed: {path} ({exc})", 1, override=True)
199 return _MISSING_SENTINEL
200
201
202def _tracked_entries(repo_root: Path) -> list[tuple[str, str]]:
203 """Return ``[(mode, rel_path)]`` for every entry in the index.
204
205 Enumerates with ``git ls-files -z --stage``, whose records are
206 ``<mode> <object> <stage>\\t<path>`` separated by NUL. ``-z`` is
207 what keeps a non-ASCII path out of ``core.quotepath``'s quoting
208 and octal-escaping, and ``--stage`` is what makes a gitlink
209 distinguishable from a blob. Raises whatever
210 :func:`oct.core.git.git_run` raises - a git failure is the
211 caller's fail-soft business, not this function's.
212 """
213 func = "_tracked_entries"
214 from oct.core.git import git_run
215
216 proc = git_run(["ls-files", "-z", "--stage"], cwd=Path(repo_root))
217 entries: list[tuple[str, str]] = []
218 for record in proc.stdout.split("\0"):
219 if not record:
220 continue
221 meta, sep, rel = record.partition("\t")
222 if not sep or not rel:
223 _dbg(
224 "GIT", func,
225 f"unparsable ls-files record: {record!r}",
226 1, override=True,
227 )
228 continue
229 entries.append((meta.split(" ", 1)[0], rel))
230 _dbg("GIT", func, f"index entries={len(entries)}", 4)
231 return entries
232
233
234def _dirty_paths(repo_root: Path) -> set[str]:
235 """Return the repo-relative paths git reports as differing from the index.
236
237 ``git diff --name-only -z`` compares the working tree against the
238 index - the same question ``git commit`` itself asks - and
239 includes deletions. Untracked files are absent by construction.
240 """
241 func = "_dirty_paths"
242 from oct.core.git import git_run
243
244 proc = git_run(["diff", "--name-only", "-z"], cwd=Path(repo_root))
245 dirty = {p for p in proc.stdout.split("\0") if p}
246 _dbg("GIT", func, f"dirty={len(dirty)}", 4)
247 return dirty
248
249
250def _make_escape_check(repo_root: Path):
251 """Return ``is_inside(mode, rel) -> bool`` for index paths.
252
253 Answers exactly the question the pre-242 code asked per file -
254 "does this tracked path resolve inside *repo_root*?" - without
255 paying a ``Path.resolve()`` syscall per file. A tracked path can
256 only land outside the repository if one of its parent directories
257 is a symlink out, or if its own leaf is one; git names the second
258 case in the index (mode :data:`_SYMLINK_MODE`), so the first is
259 resolved once per distinct parent directory and cached, and the
260 second per entry.
261
262 Measured on this monorepo's 13,394-entry index: 2.142 s of
263 per-file resolves collapses to 0.048 s across 306 directories,
264 which is what brings a git-native pass inside its budget. Only
265 :class:`ValueError` is caught, matching the pre-242 behaviour -
266 an :class:`OSError` still propagates to the caller's fail-soft.
267 """
268 func = "_make_escape_check"
269 repo_resolved = Path(repo_root).resolve()
270 dir_cache: dict[str, bool] = {}
271 _dbg("GIT", func, f"escape check built for {repo_resolved}", 4)
272
273 def _dir_inside(rel_dir: str) -> bool:
274 func = "_make_escape_check._dir_inside"
275 cached = dir_cache.get(rel_dir)
276 if cached is None:
277 try:
278 (repo_root / rel_dir).resolve().relative_to(repo_resolved)
279 cached = True
280 except ValueError:
281 _dbg(
282 "GIT", func,
283 f"path-escape directory: {rel_dir}", 1, override=True,
284 )
285 cached = False
286 dir_cache[rel_dir] = cached
287 return cached
288
289 def is_inside(mode: str, rel: str) -> bool:
290 if not _dir_inside(rel.rpartition("/")[0]):
291 return False
292 if mode != _SYMLINK_MODE:
293 return True
294 try:
295 (repo_root / rel).resolve().relative_to(repo_resolved)
296 except ValueError:
297 return False
298 return True
299
300 return is_inside
301
302
304 repo_root: Path,
305 staged_set: set[str] | None = None,
306 *,
307 method: str = METHOD_HASH,
308 also_hash: set[str] | None = None,
309) -> dict[str, str]:
310 """Return ``{rel_path: digest_or_sentinel}`` for tracked-but-unstaged files.
311
312 *repo_root* must be the actual git repository root (not the OCT
313 project root in monorepo layouts). *staged_set* is a set of
314 repo-relative path strings already in the index; those files are
315 excluded from the snapshot because their mutations are caller
316 intent, not silent regression.
317
318 *method* selects how each recorded value is produced -
319 :data:`METHOD_HASH` content-hashes every entry, :data:`METHOD_GIT`
320 content-hashes only what git reports as dirty (plus *also_hash*)
321 and records :data:`_CLEAN_SENTINEL` for the rest. See the module
322 docstring for the trade-off and the accepted residual. An unknown
323 *method* raises :class:`ValueError`.
324
325 *also_hash* is the pre snapshot's set of paths that carried
326 anything other than :data:`_CLEAN_SENTINEL`. Passing it on the
327 post snapshot is what keeps a hash-to-hash comparison exact for a
328 file that was already dirty when the commit started: without it, a
329 file reverted to its index content between the two snapshots would
330 leave the dirty set and read as unchanged. Ignored under
331 :data:`METHOD_HASH`, which hashes everything anyway.
332
333 The snapshot includes:
334 - Tracked files that are clean (working tree == index).
335 - Tracked files modified but not staged (``M`` in
336 ``git status -s`` second column).
337
338 The snapshot excludes:
339 - Files in *staged_set*.
340 - Untracked files (``??``).
341 - Gitlink entries (mode :data:`_GITLINK_MODE`) and worktree
342 symlinks pointing outside *repo_root*.
343 """
344 func = "snapshot_unstaged_tracked"
345 if method not in OBSERVE_METHODS:
346 raise ValueError(
347 f"unknown commit-observer method {method!r}; expected one "
348 f"of {', '.join(OBSERVE_METHODS)}"
349 )
350 _dbg("GIT", func, f"repo_root={repo_root} method={method}", 2)
351
352 if staged_set is None:
353 staged_set = set()
354
355 is_inside = _make_escape_check(repo_root)
356 candidates: dict[str, Path] = {}
357 gitlinks = 0
358 for mode, rel in _tracked_entries(repo_root):
359 if rel in staged_set:
360 continue
361 if mode == _GITLINK_MODE:
362 # A nested repository, not a blob. Reading it as a file is
363 # what produced a permission-denied L1 line on every
364 # snapshot before plan 242.
365 gitlinks += 1
366 _dbg("GIT", func, f"gitlink skip: {rel}", 4)
367 continue
368 if not is_inside(mode, rel):
369 # Path escape - drop, don't hash.
370 _dbg("GIT", func, f"path-escape skip: {rel}", 1, override=True)
371 continue
372 candidates[rel] = repo_root / rel
373
374 snapshot: dict[str, str] = {}
375 if method == METHOD_HASH:
376 for rel, full in candidates.items():
377 snapshot[rel] = _hash_file(full)
378 hashed = len(snapshot)
379 else:
380 to_hash = _dirty_paths(repo_root)
381 if also_hash:
382 to_hash = to_hash | set(also_hash)
383 hashed = 0
384 for rel, full in candidates.items():
385 if rel in to_hash:
386 snapshot[rel] = _hash_file(full)
387 hashed += 1
388 else:
389 snapshot[rel] = _CLEAN_SENTINEL
390
391 _dbg(
392 "GIT", func,
393 f"snapshot complete: {len(snapshot)} file(s) via {method} "
394 f"(hashed={hashed}, staged={len(staged_set)} excluded, "
395 f"gitlinks={gitlinks} skipped)",
396 3,
397 )
398 return snapshot
399
400
402 before: dict[str, str],
403 after: dict[str, str],
404) -> list[str]:
405 """Return the list of paths whose digest changed between snapshots.
406
407 Includes:
408 - Paths present in both with different digests (mutation).
409 - Paths present in *before* but missing from *after* (deletion or
410 unreadable).
411 - Paths newly tracked in *after* (rare; would only appear if
412 ``git add`` ran during the commit pipeline).
413
414 A path with the same :data:`_OVERSIZE_SENTINEL` on both sides is
415 NOT flagged - we cannot tell whether the content of an oversize
416 file changed without a full hash. Document this caveat to the
417 caller via the per-file payload (omitted from the simple list
418 return; see :func:`diff_snapshots_detail` for the verbose form).
419
420 :data:`_CLEAN_SENTINEL` needs no special case: it compares as an
421 ordinary value, so ``CLEAN`` on both sides is no mutation, and
422 ``CLEAN`` against a digest (a file that entered or left the dirty
423 set between the snapshots) is one. Plan 242 left this function
424 unchanged on purpose - both detection methods feed it.
425 """
426 func = "diff_snapshots"
427 keys = set(before.keys()) | set(after.keys())
428 changed: list[str] = []
429 for k in keys:
430 b = before.get(k, _MISSING_SENTINEL)
431 a = after.get(k, _MISSING_SENTINEL)
432 if b == _OVERSIZE_SENTINEL and a == _OVERSIZE_SENTINEL:
433 continue
434 if b != a:
435 changed.append(k)
436 changed.sort()
437 _dbg("GIT", func, f"changed={len(changed)}", 3)
438 return changed
dict[str, str] snapshot_unstaged_tracked(Path repo_root, set[str]|None staged_set=None, *, str method=METHOD_HASH, set[str]|None also_hash=None)
list[str] diff_snapshots(dict[str, str] before, dict[str, str] after)
_make_escape_check(Path repo_root)
set[str] _dirty_paths(Path repo_root)
list[tuple[str, str]] _tracked_entries(Path repo_root)