Option C Tools
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
16Responsibilities
17----------------
18- Provide :func:`snapshot_unstaged_tracked` — returns a mapping of
19 project-relative path -> sha256 hex digest for every tracked file
20 *not* in the staged set.
21- Provide :func:`diff_snapshots` — returns the list of paths whose
22 hash changed (new file, deleted file, or content drift) between two
23 snapshots.
24- Be defensive: large files (> :data:`_MAX_HASH_BYTES`) are recorded
25 with the sentinel digest ``"OVERSIZE"`` so they don't dominate
26 commit latency, and a missing/unreadable file maps to ``""`` so the
27 diff path treats it consistently.
28
29Diagnostics
30-----------
31Domain: GIT
32Levels:
33 L1 — errors: file IO failures during snapshot
34 L2 — lifecycle: snapshot start / snapshot complete
35 L3 — details: file count, total bytes hashed
36 L4 — deep trace: per-file hash + size
37
38Contracts
39---------
40- This module is pure: no Click, no subprocess of its own. It uses
41 :func:`oct.core.git.git_run` to enumerate tracked files.
42- Snapshot calls must never raise on individual-file IO errors —
43 they record the file as unreadable and continue.
44- The hash digest is content-only (no metadata). Renames are detected
45 via the path key, not the digest.
46"""
47
48from __future__ import annotations
49
50import hashlib
51from pathlib import Path
52
53from oct.core.diagnostics import _dbg
54
55
56#: Files larger than this are recorded with sentinel ``"OVERSIZE"``
57#: rather than hashed. Keeps the snapshot cost bounded on repos with
58#: large generated artefacts checked into git.
59_MAX_HASH_BYTES: int = 10 * 1024 * 1024 # 10 MB
60
61#: Sentinel digest used when a file exceeds :data:`_MAX_HASH_BYTES`.
62_OVERSIZE_SENTINEL: str = "OVERSIZE"
63
64#: Sentinel digest used when a file is missing or unreadable. Empty
65#: string is distinct from any valid sha256 hex digest.
66_MISSING_SENTINEL: str = ""
67
68
69def _hash_file(path: Path) -> str:
70 """Return the sha256 hex digest of *path*, or a sentinel.
71
72 Returns :data:`_OVERSIZE_SENTINEL` when the file is larger than
73 :data:`_MAX_HASH_BYTES`, and :data:`_MISSING_SENTINEL` when the
74 file is missing or unreadable. Never raises.
75 """
76 func = "_hash_file"
77 try:
78 size = path.stat().st_size
79 except OSError as exc:
80 _dbg("GIT", func, f"stat failed: {path} ({exc})", 1, override=True)
81 return _MISSING_SENTINEL
82
83 if size > _MAX_HASH_BYTES:
84 _dbg("GIT", func, f"oversize: {path} ({size} bytes)", 4)
85 return _OVERSIZE_SENTINEL
86
87 try:
88 h = hashlib.sha256()
89 with path.open("rb") as fh:
90 while True:
91 chunk = fh.read(65536)
92 if not chunk:
93 break
94 h.update(chunk)
95 digest = h.hexdigest()
96 _dbg("GIT", func, f"hashed {path} -> {digest[:8]}…", 4)
97 return digest
98 except OSError as exc:
99 _dbg("GIT", func, f"read failed: {path} ({exc})", 1, override=True)
100 return _MISSING_SENTINEL
101
102
104 repo_root: Path,
105 staged_set: set[str] | None = None,
106) -> dict[str, str]:
107 """Return ``{rel_path: sha256_hex}`` for every tracked-but-unstaged file.
108
109 *repo_root* must be the actual git repository root (not the OCT
110 project root in monorepo layouts). *staged_set* is a set of
111 repo-relative path strings already in the index; those files are
112 excluded from the snapshot because their mutations are caller
113 intent, not silent regression.
114
115 The snapshot includes:
116 - Tracked files that are clean (working tree == HEAD).
117 - Tracked files modified but not staged (``M`` in
118 ``git status -s`` second column).
119
120 The snapshot excludes:
121 - Files in *staged_set*.
122 - Untracked files (``??``).
123 - Submodules and worktree symlinks pointing outside *repo_root*.
124 """
125 func = "snapshot_unstaged_tracked"
126 _dbg("GIT", func, f"repo_root={repo_root}", 2)
127 from oct.core.git import git_run
128
129 if staged_set is None:
130 staged_set = set()
131
132 proc = git_run(["ls-files"], cwd=Path(repo_root))
133 snapshot: dict[str, str] = {}
134 for raw in proc.stdout.splitlines():
135 rel = raw.strip()
136 if not rel or rel in staged_set:
137 continue
138 full = (repo_root / rel).resolve()
139 try:
140 full.relative_to(Path(repo_root).resolve())
141 except ValueError:
142 # Path escape — drop, don't hash.
143 _dbg("GIT", func, f"path-escape skip: {rel}", 1, override=True)
144 continue
145 snapshot[rel] = _hash_file(full)
146
147 _dbg(
148 "GIT", func,
149 f"snapshot complete: {len(snapshot)} file(s) "
150 f"(staged={len(staged_set)} excluded)",
151 3,
152 )
153 return snapshot
154
155
157 before: dict[str, str],
158 after: dict[str, str],
159) -> list[str]:
160 """Return the list of paths whose digest changed between snapshots.
161
162 Includes:
163 - Paths present in both with different digests (mutation).
164 - Paths present in *before* but missing from *after* (deletion or
165 unreadable).
166 - Paths newly tracked in *after* (rare; would only appear if
167 ``git add`` ran during the commit pipeline).
168
169 A path with the same :data:`_OVERSIZE_SENTINEL` on both sides is
170 NOT flagged — we cannot tell whether the content of an oversize
171 file changed without a full hash. Document this caveat to the
172 caller via the per-file payload (omitted from the simple list
173 return; see :func:`diff_snapshots_detail` for the verbose form).
174 """
175 func = "diff_snapshots"
176 keys = set(before.keys()) | set(after.keys())
177 changed: list[str] = []
178 for k in keys:
179 b = before.get(k, _MISSING_SENTINEL)
180 a = after.get(k, _MISSING_SENTINEL)
181 if b == _OVERSIZE_SENTINEL and a == _OVERSIZE_SENTINEL:
182 continue
183 if b != a:
184 changed.append(k)
185 changed.sort()
186 _dbg("GIT", func, f"changed={len(changed)}", 3)
187 return changed
list[str] diff_snapshots(dict[str, str] before, dict[str, str] after)
dict[str, str] snapshot_unstaged_tracked(Path repo_root, set[str]|None staged_set=None)