Coverage for src / rhiza / models / _git / context.py: 100%
40 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"""The public :class:`GitContext` facade composing the git engine mixins."""
3import os
4import subprocess # nosec B404
5from dataclasses import dataclass, field
6from pathlib import Path
8from loguru import logger
10from rhiza.models._git.helpers import _log_git_stderr_errors, get_git_executable
11from rhiza.models._git.merge import MergeMixin
14@dataclass
15class GitContext(MergeMixin):
16 """Bundles the git executable path and environment for subprocess calls.
18 All git-invoking functions in the sync helpers accept a
19 :class:`GitContext` instead of resolving the executable on their own,
20 making them easily testable via binary injection.
22 The git operations are organised into focused mixins
23 (:class:`~rhiza.models._git.remote.RemoteOpsMixin`,
24 :class:`~rhiza.models._git.diff.DiffMixin`,
25 :class:`~rhiza.models._git.merge.MergeMixin`); this class composes them and
26 adds the working-tree/branch operations. See ADR-0005 for the rationale.
28 Attributes:
29 executable: Absolute path to the git binary.
30 env: Environment variables passed to every git subprocess.
31 """
33 executable: str
34 env: dict[str, str] = field(default_factory=dict)
36 @classmethod
37 def default(cls) -> "GitContext":
38 """Create a GitContext using the system git and process environment.
40 Returns:
41 A :class:`GitContext` populated with the real git executable path
42 and a copy of the current process environment with
43 ``GIT_TERMINAL_PROMPT`` set to ``"0"``.
44 """
45 env = os.environ.copy()
46 env["GIT_TERMINAL_PROMPT"] = "0"
47 return cls(executable=get_git_executable(), env=env)
49 def assert_status_clean(self, target: Path) -> None:
50 """Raise RuntimeError if the target repository has uncommitted changes.
52 Runs ``git status --porcelain`` and raises if the output is non-empty,
53 preventing a sync from running on a dirty working tree.
55 Args:
56 target: Path to the target repository.
58 Raises:
59 RuntimeError: If the working tree has uncommitted changes.
60 """
61 result = subprocess.run( # nosec B603 # noqa: S603
62 [self.executable, "status", "--porcelain"],
63 cwd=target,
64 capture_output=True,
65 text=True,
66 env=self.env,
67 )
68 if result.stdout.strip():
69 logger.error("Working tree is not clean. Please commit or stash your changes before syncing.")
70 logger.error("Uncommitted changes:")
71 for line in result.stdout.strip().splitlines():
72 logger.error(f" {line}")
73 raise RuntimeError("Working tree is not clean. Please commit or stash your changes before syncing.") # noqa: TRY003
75 def handle_target_branch(self, target: Path, target_branch: str | None) -> None:
76 """Handle target branch creation or checkout if specified.
78 Args:
79 target: Path to the target repository.
80 target_branch: Optional branch name to create/checkout.
81 """
82 if not target_branch:
83 return
85 logger.info(f"Creating/checking out target branch: {target_branch}")
86 try:
87 result = subprocess.run( # nosec B603 # noqa: S603
88 [self.executable, "rev-parse", "--verify", target_branch],
89 cwd=target,
90 capture_output=True,
91 text=True,
92 env=self.env,
93 )
95 if result.returncode == 0:
96 logger.info(f"Branch '{target_branch}' exists, checking out...")
97 subprocess.run( # nosec B603 # noqa: S603
98 [self.executable, "checkout", target_branch],
99 cwd=target,
100 check=True,
101 capture_output=True,
102 text=True,
103 env=self.env,
104 )
105 else:
106 logger.info(f"Creating new branch '{target_branch}'...")
107 subprocess.run( # nosec B603 # noqa: S603
108 [self.executable, "checkout", "-b", target_branch],
109 cwd=target,
110 check=True,
111 capture_output=True,
112 text=True,
113 env=self.env,
114 )
115 except subprocess.CalledProcessError as e:
116 logger.error(f"Failed to create/checkout branch '{target_branch}'")
117 _log_git_stderr_errors(e.stderr)
118 logger.error("Please ensure you have no uncommitted changes or conflicts")
119 raise