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.
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
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.
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
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.
48from __future__
import annotations
51from pathlib
import Path
53from oct.core.diagnostics
import _dbg
59_MAX_HASH_BYTES: int = 10 * 1024 * 1024
62_OVERSIZE_SENTINEL: str =
"OVERSIZE"
66_MISSING_SENTINEL: str =
""
70 """Return the sha256 hex digest of *path*, or a sentinel.
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.
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
83 if size > _MAX_HASH_BYTES:
84 _dbg(
"GIT", func, f
"oversize: {path} ({size} bytes)", 4)
85 return _OVERSIZE_SENTINEL
89 with path.open(
"rb")
as fh:
91 chunk = fh.read(65536)
95 digest = h.hexdigest()
96 _dbg(
"GIT", func, f
"hashed {path} -> {digest[:8]}…", 4)
98 except OSError
as exc:
99 _dbg(
"GIT", func, f
"read failed: {path} ({exc})", 1, override=
True)
100 return _MISSING_SENTINEL
105 staged_set: set[str] |
None =
None,
107 """Return ``{rel_path: sha256_hex}`` for every tracked-but-unstaged file.
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.
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).
120 The snapshot excludes:
121 - Files in *staged_set*.
122 - Untracked files (``??``).
123 - Submodules and worktree symlinks pointing outside *repo_root*.
125 func =
"snapshot_unstaged_tracked"
126 _dbg(
"GIT", func, f
"repo_root={repo_root}", 2)
127 from oct.core.git
import git_run
129 if staged_set
is None:
132 proc = git_run([
"ls-files"], cwd=Path(repo_root))
133 snapshot: dict[str, str] = {}
134 for raw
in proc.stdout.splitlines():
136 if not rel
or rel
in staged_set:
138 full = (repo_root / rel).resolve()
140 full.relative_to(Path(repo_root).resolve())
143 _dbg(
"GIT", func, f
"path-escape skip: {rel}", 1, override=
True)
149 f
"snapshot complete: {len(snapshot)} file(s) "
150 f
"(staged={len(staged_set)} excluded)",
157 before: dict[str, str],
158 after: dict[str, str],
160 """Return the list of paths whose digest changed between snapshots.
163 - Paths present in both with different digests (mutation).
164 - Paths present in *before* but missing from *after* (deletion or
166 - Paths newly tracked in *after* (rare; would only appear if
167 ``git add`` ran during the commit pipeline).
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).
175 func =
"diff_snapshots"
176 keys = set(before.keys()) | set(after.keys())
177 changed: list[str] = []
179 b = before.get(k, _MISSING_SENTINEL)
180 a = after.get(k, _MISSING_SENTINEL)
181 if b == _OVERSIZE_SENTINEL
and a == _OVERSIZE_SENTINEL:
186 _dbg(
"GIT", func, f
"changed={len(changed)}", 3)
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)
str _hash_file(Path path)