Coverage for src / rhiza / models / _git / diff.py: 100%
72 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
1"""Diff computation and parsing for the sync engine."""
3import subprocess # nosec B404
4from dataclasses import dataclass
5from pathlib import Path
7from loguru import logger
9from rhiza.models._git._base import GitContextBase
11_SRC_PREFIX = "upstream-template-old/"
12_DST_PREFIX = "upstream-template-new/"
15def _path_after(line: str, marker: str, prefix: str) -> str | None:
16 """Return the diff path on a ``---``/``+++`` header line, stripped of *prefix*."""
17 raw = line[len(marker) :].strip().strip('"').split("\t")[0]
18 if raw != "/dev/null" and raw.startswith(prefix):
19 return raw[len(prefix) :]
20 return None
23@dataclass
24class _DiffFileState:
25 """Accumulates the per-file flags/paths seen while scanning one ``diff --git`` block."""
27 is_new: bool = False
28 is_deleted: bool = False
29 src_path: str | None = None
30 dst_path: str | None = None
31 started: bool = False
33 def reset(self) -> None:
34 """Begin a new file block, clearing all accumulated state."""
35 self.is_new = False
36 self.is_deleted = False
37 self.src_path = None
38 self.dst_path = None
39 self.started = True
41 def update(self, line: str) -> None:
42 """Update state from a single non-``diff --git`` header line."""
43 if line.startswith("new file mode"):
44 self.is_new = True
45 elif line.startswith("deleted file mode"):
46 self.is_deleted = True
47 elif line.startswith("--- "):
48 self.src_path = _path_after(line, "--- ", _SRC_PREFIX) or self.src_path
49 elif line.startswith("+++ "):
50 self.dst_path = _path_after(line, "+++ ", _DST_PREFIX) or self.dst_path
52 def entry(self) -> tuple[str, bool, bool] | None:
53 """Return the ``(rel_path, is_new, is_deleted)`` entry for this block, if a path was captured."""
54 rel = self.src_path if self.is_deleted else self.dst_path
55 return (rel, self.is_new, self.is_deleted) if rel else None
58class DiffMixin(GitContextBase):
59 """Compute and parse diffs between template snapshot trees."""
61 def get_diff(self, repo0: Path, repo1: Path) -> str:
62 """Compute the raw diff between two directory trees using ``git diff --no-index``.
64 Args:
65 repo0: Path to the base (old) directory tree.
66 repo1: Path to the upstream (new) directory tree.
67 """
68 repo0_str = repo0.resolve().as_posix()
69 repo1_str = repo1.resolve().as_posix()
70 result = subprocess.run( # nosec B603 # noqa: S603
71 [
72 self.executable,
73 "-c",
74 "diff.noprefix=",
75 "diff",
76 "--no-index",
77 "--relative",
78 "--binary",
79 "--src-prefix=upstream-template-old/",
80 "--dst-prefix=upstream-template-new/",
81 "--no-ext-diff",
82 "--no-color",
83 repo0_str,
84 repo1_str,
85 ],
86 cwd=repo0_str,
87 capture_output=True,
88 env=self.env,
89 )
90 diff = result.stdout.decode() if isinstance(result.stdout, bytes) else (result.stdout or "")
91 for repo in [repo0_str, repo1_str]:
92 from re import sub
94 repo_nix = sub("/[a-z]:", "", repo)
95 diff = diff.replace(f"upstream-template-old{repo_nix}", "upstream-template-old").replace(
96 f"upstream-template-new{repo_nix}", "upstream-template-new"
97 )
98 diff = diff.replace(repo0_str + "/", "").replace(repo1_str + "/", "")
99 return diff
101 def sync_diff(self, target: Path, upstream_snapshot: Path) -> None:
102 """Execute the diff (dry-run) strategy.
104 Shows what would change without modifying any files.
106 Args:
107 target: Path to the target repository.
108 upstream_snapshot: Path to the upstream snapshot directory.
109 """
110 diff = self.get_diff(target, upstream_snapshot)
111 if diff.strip():
112 logger.info(f"\n{diff}")
113 changes = diff.count("diff --git")
114 logger.info(f"{changes} file(s) would be changed")
115 else:
116 logger.success("No differences found")
118 def _parse_diff_filenames(self, diff: str) -> list[tuple[str, bool, bool]]:
119 """Parse a unified diff produced by :func:`DiffMixin.get_diff` into file entries.
121 Each entry is ``(rel_path, is_new, is_deleted)`` where *rel_path* is the
122 path relative to both snapshot directories.
124 Args:
125 diff: Unified diff string from :func:`DiffMixin.get_diff`.
127 Returns:
128 List of ``(rel_path, is_new, is_deleted)`` tuples, one per changed file.
129 """
130 results: list[tuple[str, bool, bool]] = []
131 state = _DiffFileState()
133 def _flush() -> None:
134 """Emit the current file entry into results if a path was captured."""
135 entry = state.entry()
136 if entry:
137 results.append(entry)
139 for line in diff.splitlines():
140 if line.startswith("diff --git "):
141 if state.started:
142 _flush()
143 state.reset()
144 else:
145 state.update(line)
147 if state.started:
148 _flush()
150 return results