|
oct
|
Functions | |
| str | _hash_file (Path path) |
| list[tuple[str, str]] | _tracked_entries (Path repo_root) |
| set[str] | _dirty_paths (Path repo_root) |
| _make_escape_check (Path repo_root) | |
| 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) |
Variables | |
| int | _MAX_HASH_BYTES = 10 * 1024 * 1024 |
| str | _OVERSIZE_SENTINEL = "OVERSIZE" |
| str | _MISSING_SENTINEL = "" |
| str | _CLEAN_SENTINEL = "CLEAN" |
| str | METHOD_GIT = "git" |
| str | METHOD_HASH = "hash" |
| tuple | OBSERVE_METHODS = (METHOD_GIT, METHOD_HASH) |
| str | _GITLINK_MODE = "160000" |
| str | _SYMLINK_MODE = "120000" |
Purpose
-------
Detect silent mutations of tracked-but-unstaged files during the
``oct git commit`` pipeline (B1.0). Snapshots the working-tree state
of every tracked file outside the staged set at the start of the
commit, then compares again right before and right after the
underlying ``git commit`` invocation. Any byte-level change is
reported as either a warning or a hard abort, depending on the
``git.observe_unstaged_mutation`` octrc setting.
Two detection methods produce those snapshots (plan 242). They agree
on every verdict the control exists to produce; they differ only in
what they read to reach it.
``git`` (:data:`METHOD_GIT`)
Ask git which tracked files differ from the index
(``git diff --name-only -z``, answered from git's stat cache with
the racy-git re-hash every ``git commit`` already trusts) and
content-hash only those, plus the paths named in *also_hash*.
Every other tracked file carries :data:`_CLEAN_SENTINEL`. About
0.2 s per pass on a 13k-file tree.
``hash`` (:data:`METHOD_HASH`)
SHA-256 every tracked-but-unstaged file. Byte-identical to the
pre-242 behaviour apart from the two enumeration fixes below.
188 s per pass on the same tree with the OS file cache cold.
The commit flow resolves the method from the octrc key
``git.observe_unstaged_mutation_method`` and always passes it
explicitly; ``git`` is the default when the key is absent. This
function's own parameter defaults to ``hash``, the more thorough of
the two, so that a caller who names no method never silently gets
less observation than it did before plan 242.
Responsibilities
----------------
- Provide :func:`snapshot_unstaged_tracked` - returns a mapping of
repo-relative path -> sha256 hex digest or sentinel for every
tracked file *not* in the staged set.
- Provide :func:`diff_snapshots` - returns the list of paths whose
digest changed (new file, deleted file, or content drift) between
two snapshots. Unchanged by plan 242; both methods feed it.
- Be defensive: large files (> :data:`_MAX_HASH_BYTES`) are recorded
with the sentinel digest ``"OVERSIZE"`` so they don't dominate
commit latency, and a missing/unreadable file maps to ``""`` so the
diff path treats it consistently.
- Enumerate the index with ``git ls-files -z --stage``, which fixes
two observation blind spots that were silent before plan 242:
1. Without ``-z``, ``git ls-files`` quotes and octal-escapes any
name containing a non-ASCII character (``core.quotepath``
defaults to true). The observer stat-failed on the quoted
string, recorded the missing sentinel, and never observed the
file - a mutation of it was invisible to the control.
2. Gitlink entries (mode ``160000``, a nested repository) came out
of ``git ls-files`` looking like files. The observer tried to
open the directory and logged a permission-denied L1 override on
every snapshot. They are now skipped by mode, not by luck.
Diagnostics
-----------
Domain: GIT
Levels:
L1 - errors: file IO failures during snapshot, unparsable index
records, path escapes
L2 - lifecycle: snapshot start / snapshot complete
L3 - details: file count, hashed count, gitlinks skipped
L4 - deep trace: per-file hash + size
Contracts
---------
- This module is pure: no Click, no subprocess of its own. It uses
:func:`oct.core.git.git_run` to enumerate tracked files and, under
:data:`METHOD_GIT`, to ask for the dirty set.
- Snapshot calls must never raise on individual-file IO errors -
they record the file as unreadable and continue. A failure of git
*itself* does propagate: the caller's fail-soft wrapper turns the
observer off for that commit rather than silently switching method,
which would reintroduce the cost this module exists to remove.
- An unknown *method* is a programming error and raises
:class:`ValueError`; there is no permissive fallback in either
direction.
- The hash digest is content-only (no metadata). Renames are detected
via the path key, not the digest.
- A tracked path that resolves outside *repo_root* is dropped from
the snapshot rather than hashed, under both methods. Plan 242
computes that predicate once per distinct parent directory instead
of once per file (:func:`_make_escape_check`) - the same answer,
and the difference between a git-native pass costing 0.2 s and one
costing 2.4 s on a 13k-entry index.
- Residual of :data:`METHOD_GIT`, accepted when the method was ruled
(FACTORY-01 D3 option (a)): a writer that restores a file's size
and mtime after writing evades git's stat cache where a SHA-256
walk would not, and a change visible only through a content filter
(``core.autocrlf``, a ``.gitattributes`` clean filter) is likewise
not a difference in git's eyes. Such a writer is adversarial, and
an adversarial writer can already disable the observer through
octrc, so it was never in this control's threat model. Run under
:data:`METHOD_HASH` for a paranoid pass.
- One documented verdict difference between the methods: an oversize
file that is CLEAN at the pre snapshot is recorded as
:data:`_CLEAN_SENTINEL` under ``git`` and as
:data:`_OVERSIZE_SENTINEL` under ``hash``, so a mutation of it is
flagged under ``git`` and not under ``hash`` (``diff_snapshots``
cannot compare an OVERSIZE pair). ``git`` is strictly stronger;
``hash`` keeps the pre-242 answer by design.
Dependencies
------------
- oct.core.diagnostics (_dbg structured logger)
- oct.core.git (lazy; git_run - the only subprocess path out of this module)
- hashlib (SHA-256 content digests of tracked files)
- pathlib (Path for the repo root and tracked-file paths)
|
protected |
Return the repo-relative paths git reports as differing from the index. ``git diff --name-only -z`` compares the working tree against the index - the same question ``git commit`` itself asks - and includes deletions. Untracked files are absent by construction.
Definition at line 234 of file commit_observer.py.
|
protected |
Return the sha256 hex digest of *path*, or a sentinel. Returns :data:`_OVERSIZE_SENTINEL` when the file is larger than :data:`_MAX_HASH_BYTES`, and :data:`_MISSING_SENTINEL` when the file is missing or unreadable. Never raises.
Definition at line 168 of file commit_observer.py.
|
protected |
Return ``is_inside(mode, rel) -> bool`` for index paths. Answers exactly the question the pre-242 code asked per file - "does this tracked path resolve inside *repo_root*?" - without paying a ``Path.resolve()`` syscall per file. A tracked path can only land outside the repository if one of its parent directories is a symlink out, or if its own leaf is one; git names the second case in the index (mode :data:`_SYMLINK_MODE`), so the first is resolved once per distinct parent directory and cached, and the second per entry. Measured on this monorepo's 13,394-entry index: 2.142 s of per-file resolves collapses to 0.048 s across 306 directories, which is what brings a git-native pass inside its budget. Only :class:`ValueError` is caught, matching the pre-242 behaviour - an :class:`OSError` still propagates to the caller's fail-soft.
Definition at line 250 of file commit_observer.py.
|
protected |
Return ``[(mode, rel_path)]`` for every entry in the index. Enumerates with ``git ls-files -z --stage``, whose records are ``<mode> <object> <stage>\\t<path>`` separated by NUL. ``-z`` is what keeps a non-ASCII path out of ``core.quotepath``'s quoting and octal-escaping, and ``--stage`` is what makes a gitlink distinguishable from a blob. Raises whatever :func:`oct.core.git.git_run` raises - a git failure is the caller's fail-soft business, not this function's.
Definition at line 202 of file commit_observer.py.
| list[str] oct.git.commit_observer.diff_snapshots | ( | dict[str, str] | before, |
| dict[str, str] | after ) |
Return the list of paths whose digest changed between snapshots.
Includes:
- Paths present in both with different digests (mutation).
- Paths present in *before* but missing from *after* (deletion or
unreadable).
- Paths newly tracked in *after* (rare; would only appear if
``git add`` ran during the commit pipeline).
A path with the same :data:`_OVERSIZE_SENTINEL` on both sides is
NOT flagged - we cannot tell whether the content of an oversize
file changed without a full hash. Document this caveat to the
caller via the per-file payload (omitted from the simple list
return; see :func:`diff_snapshots_detail` for the verbose form).
:data:`_CLEAN_SENTINEL` needs no special case: it compares as an
ordinary value, so ``CLEAN`` on both sides is no mutation, and
``CLEAN`` against a digest (a file that entered or left the dirty
set between the snapshots) is one. Plan 242 left this function
unchanged on purpose - both detection methods feed it.
Definition at line 401 of file commit_observer.py.
| dict[str, str] oct.git.commit_observer.snapshot_unstaged_tracked | ( | Path | repo_root, |
| set[str] | None | staged_set = None, | ||
| * | , | ||
| str | method = METHOD_HASH, | ||
| set[str] | None | also_hash = None ) |
Return ``{rel_path: digest_or_sentinel}`` for tracked-but-unstaged files.
*repo_root* must be the actual git repository root (not the OCT
project root in monorepo layouts). *staged_set* is a set of
repo-relative path strings already in the index; those files are
excluded from the snapshot because their mutations are caller
intent, not silent regression.
*method* selects how each recorded value is produced -
:data:`METHOD_HASH` content-hashes every entry, :data:`METHOD_GIT`
content-hashes only what git reports as dirty (plus *also_hash*)
and records :data:`_CLEAN_SENTINEL` for the rest. See the module
docstring for the trade-off and the accepted residual. An unknown
*method* raises :class:`ValueError`.
*also_hash* is the pre snapshot's set of paths that carried
anything other than :data:`_CLEAN_SENTINEL`. Passing it on the
post snapshot is what keeps a hash-to-hash comparison exact for a
file that was already dirty when the commit started: without it, a
file reverted to its index content between the two snapshots would
leave the dirty set and read as unchanged. Ignored under
:data:`METHOD_HASH`, which hashes everything anyway.
The snapshot includes:
- Tracked files that are clean (working tree == index).
- Tracked files modified but not staged (``M`` in
``git status -s`` second column).
The snapshot excludes:
- Files in *staged_set*.
- Untracked files (``??``).
- Gitlink entries (mode :data:`_GITLINK_MODE`) and worktree
symlinks pointing outside *repo_root*.
Definition at line 303 of file commit_observer.py.
|
protected |
Definition at line 144 of file commit_observer.py.
|
protected |
Definition at line 160 of file commit_observer.py.
|
protected |
Definition at line 132 of file commit_observer.py.
|
protected |
Definition at line 139 of file commit_observer.py.
|
protected |
Definition at line 135 of file commit_observer.py.
|
protected |
Definition at line 165 of file commit_observer.py.
| str oct.git.commit_observer.METHOD_GIT = "git" |
Definition at line 148 of file commit_observer.py.
| str oct.git.commit_observer.METHOD_HASH = "hash" |
Definition at line 151 of file commit_observer.py.
| tuple oct.git.commit_observer.OBSERVE_METHODS = (METHOD_GIT, METHOD_HASH) |
Definition at line 156 of file commit_observer.py.