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.
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.
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.
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.
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.
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:
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.
69 L1 - errors: file IO failures during snapshot, unparsable index
71 L2 - lifecycle: snapshot start / snapshot complete
72 L3 - details: file count, hashed count, gitlinks skipped
73 L4 - deep trace: per-file hash + size
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
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.
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)
121from __future__
import annotations
124from pathlib
import Path
126from oct.core.diagnostics
import _dbg
132_MAX_HASH_BYTES: int = 10 * 1024 * 1024
135_OVERSIZE_SENTINEL: str =
"OVERSIZE"
139_MISSING_SENTINEL: str =
""
144_CLEAN_SENTINEL: str =
"CLEAN"
148METHOD_GIT: str =
"git"
151METHOD_HASH: str =
"hash"
156OBSERVE_METHODS: tuple[str, ...] = (METHOD_GIT, METHOD_HASH)
160_GITLINK_MODE: str =
"160000"
165_SYMLINK_MODE: str =
"120000"
169 """Return the sha256 hex digest of *path*, or a sentinel.
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.
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
182 if size > _MAX_HASH_BYTES:
183 _dbg(
"GIT", func, f
"oversize: {path} ({size} bytes)", 4)
184 return _OVERSIZE_SENTINEL
188 with path.open(
"rb")
as fh:
190 chunk = fh.read(65536)
194 digest = h.hexdigest()
195 _dbg(
"GIT", func, f
"hashed {path} -> {digest[:8]}…", 4)
197 except OSError
as exc:
198 _dbg(
"GIT", func, f
"read failed: {path} ({exc})", 1, override=
True)
199 return _MISSING_SENTINEL
203 """Return ``[(mode, rel_path)]`` for every entry in the index.
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.
213 func =
"_tracked_entries"
214 from oct.core.git
import git_run
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"):
221 meta, sep, rel = record.partition(
"\t")
222 if not sep
or not rel:
225 f
"unparsable ls-files record: {record!r}",
229 entries.append((meta.split(
" ", 1)[0], rel))
230 _dbg(
"GIT", func, f
"index entries={len(entries)}", 4)
235 """Return the repo-relative paths git reports as differing from the index.
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.
241 func =
"_dirty_paths"
242 from oct.core.git
import git_run
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)
251 """Return ``is_inside(mode, rel) -> bool`` for index paths.
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
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.
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)
273 def _dir_inside(rel_dir: str) -> bool:
274 func =
"_make_escape_check._dir_inside"
275 cached = dir_cache.get(rel_dir)
278 (repo_root / rel_dir).resolve().relative_to(repo_resolved)
283 f
"path-escape directory: {rel_dir}", 1, override=
True,
286 dir_cache[rel_dir] = cached
289 def is_inside(mode: str, rel: str) -> bool:
290 if not _dir_inside(rel.rpartition(
"/")[0]):
292 if mode != _SYMLINK_MODE:
295 (repo_root / rel).resolve().relative_to(repo_resolved)
305 staged_set: set[str] |
None =
None,
307 method: str = METHOD_HASH,
308 also_hash: set[str] |
None =
None,
310 """Return ``{rel_path: digest_or_sentinel}`` for tracked-but-unstaged files.
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.
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`.
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.
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).
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*.
344 func =
"snapshot_unstaged_tracked"
345 if method
not in OBSERVE_METHODS:
347 f
"unknown commit-observer method {method!r}; expected one "
348 f
"of {', '.join(OBSERVE_METHODS)}"
350 _dbg(
"GIT", func, f
"repo_root={repo_root} method={method}", 2)
352 if staged_set
is None:
356 candidates: dict[str, Path] = {}
359 if rel
in staged_set:
361 if mode == _GITLINK_MODE:
366 _dbg(
"GIT", func, f
"gitlink skip: {rel}", 4)
368 if not is_inside(mode, rel):
370 _dbg(
"GIT", func, f
"path-escape skip: {rel}", 1, override=
True)
372 candidates[rel] = repo_root / rel
374 snapshot: dict[str, str] = {}
375 if method == METHOD_HASH:
376 for rel, full
in candidates.items():
378 hashed = len(snapshot)
382 to_hash = to_hash | set(also_hash)
384 for rel, full
in candidates.items():
389 snapshot[rel] = _CLEAN_SENTINEL
393 f
"snapshot complete: {len(snapshot)} file(s) via {method} "
394 f
"(hashed={hashed}, staged={len(staged_set)} excluded, "
395 f
"gitlinks={gitlinks} skipped)",
402 before: dict[str, str],
403 after: dict[str, str],
405 """Return the list of paths whose digest changed between snapshots.
408 - Paths present in both with different digests (mutation).
409 - Paths present in *before* but missing from *after* (deletion or
411 - Paths newly tracked in *after* (rare; would only appear if
412 ``git add`` ran during the commit pipeline).
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).
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.
426 func =
"diff_snapshots"
427 keys = set(before.keys()) | set(after.keys())
428 changed: list[str] = []
430 b = before.get(k, _MISSING_SENTINEL)
431 a = after.get(k, _MISSING_SENTINEL)
432 if b == _OVERSIZE_SENTINEL
and a == _OVERSIZE_SENTINEL:
437 _dbg(
"GIT", func, f
"changed={len(changed)}", 3)
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)
str _hash_file(Path path)
list[tuple[str, str]] _tracked_entries(Path repo_root)