8Apply one mutant to a real file and guarantee the pristine bytes come back.
12- Refuse a target that git cannot restore before anything is written.
13- Record the pristine bytes, write the mutant, and restore unconditionally.
14- Preserve the target's declared source encoding when writing a mutant.
21 L3 - apply and restore details
26- Restoration runs in a ``finally``, so it also executes on ``KeyboardInterrupt``
27 and on any exception raised inside the managed block.
28- Restoration writes the recorded bytes back unconditionally, so it repairs a
29 target that the managed block replaced rather than merely mutated.
30- A refusal is raised before the first write, never after a partial one.
31- This module makes no claim about processes it did not start: a child that
32 outlives the managed block can still observe or rewrite the target, which is
33 a named residual rather than a closed gap.
37- oct.core.git (repository discovery and working-tree state, read-only)
38- oct.mutation.engine (mutant application)
39- tokenize (declared source encodings)
45from __future__
import annotations
49from contextlib
import contextmanager
50from pathlib
import Path
51from typing
import Iterator
53from oct.core
import git
as core_git
58 """A named refusal: the run stopped before any file was written."""
62 """Return the repository root for a target git can restore, or refuse.
64 The refusal is the point of the function. Mutation rewrites a real file in
65 place, so the only honest precondition is that a committed copy exists to
66 fall back on if the process dies between the write and the restore. An
67 untracked or already-modified target has no such copy.
69 resolved = Path(path).resolve()
70 if not resolved.is_file():
71 raise MutationRefusal(f
"target is not a file: {resolved}")
72 if not core_git.is_git_repo(resolved.parent):
73 raise MutationRefusal(
74 f
"target is not inside a git repository: {resolved}. Mutation "
75 "rewrites the file in place and will not touch a target that "
76 "cannot be restored from git if this process dies."
79 repo_root = core_git.get_repo_root(resolved.parent)
80 except core_git.GitError
as exc:
81 raise MutationRefusal(f
"could not resolve the repository for {resolved}: {exc}")
from exc
89 """Return the target's repo-relative posix path for git plumbing calls."""
91 return resolved.relative_to(repo_root).as_posix()
92 except ValueError
as exc:
94 f
"target {resolved} is not inside its own repository root {repo_root}"
99 """Refuse a target git does not track."""
101 proc = core_git.git_run(
102 [
"ls-files",
"--error-unmatch",
"--", relative],
106 except core_git.GitError
as exc:
107 raise MutationRefusal(f
"could not query git for {relative}: {exc}")
from exc
108 if proc.returncode != 0:
110 f
"target is not tracked by git: {relative}. An untracked file has "
111 "no committed copy to restore from, so it is never mutated."
116 """Refuse a target that carries uncommitted working-tree or index changes."""
118 proc = core_git.git_run(
119 [
"status",
"--porcelain",
"--", relative],
123 except core_git.GitError
as exc:
124 raise MutationRefusal(f
"could not query git for {relative}: {exc}")
from exc
125 if proc.returncode != 0:
127 f
"git status failed for {relative}: {proc.stderr.strip()}"
129 if proc.stdout.strip():
131 f
"target has uncommitted changes: {relative}. Commit or stash them "
132 "first; mutation must be able to restore the file from git."
137 """Return a target's pristine bytes, decoded source, and declared encoding."""
138 resolved = Path(path)
139 pristine = resolved.read_bytes()
140 encoding, _ = tokenize.detect_encoding(io.BytesIO(pristine).readline)
141 return pristine, pristine.decode(encoding), encoding
150 """Write one mutant to ``path``, yield its text, and always restore.
152 The pristine bytes are captured before anything else happens and written
153 back in a ``finally``, so restoration survives an exception in the body, a
154 ``KeyboardInterrupt``, and a body that replaced the file outright. The
155 restore is a plain unconditional write rather than a conditional repair:
156 comparing first would leave a window where a crash between the comparison
157 and the write loses the original.
159 resolved = Path(path)
162 mutated_source = apply_mutant(source, mutant_id, relpath)
163 resolved.write_bytes(mutated_source.encode(encoding))
166 resolved.write_bytes(pristine)
None _refuse_untracked(str relative, Path repo_root)
Iterator[str] mutated_target(str|Path path, str mutant_id, str relpath)
Path ensure_recoverable_target(str|Path path)
None _refuse_dirty(str relative, Path repo_root)
str _repo_relative(Path resolved, Path repo_root)
tuple[bytes, str, str] read_target(str|Path path)