#!/usr/bin/env python3 """Small, conservative Git helper for the Labyricorn management launcher.""" from __future__ import annotations from dataclasses import dataclass, replace import os from pathlib import Path import re import shutil import subprocess from urllib.parse import urlsplit, urlunsplit SYNCED = "synchronized" REMOTE_AHEAD = "remote-ahead" LOCAL_AHEAD = "local-ahead" DIVERGED = "diverged" UNAVAILABLE = "unavailable" class GitRepositoryError(Exception): """An expected Git problem that can be displayed directly to a user.""" class GitAuthenticationError(GitRepositoryError): """Git could reach the remote but its HTTPS credential was rejected.""" @dataclass(frozen=True) class RepositoryState: root: Path repository_name: str branch: str origin_url: str | None upstream: str | None upstream_branch: str | None dirty_count: int ahead: int | None behind: int | None sync_state: str problem: str | None = None origin_push_url: str | None = None @property def status_text(self) -> str: if self.sync_state == SYNCED: return "Up to date" if self.sync_state == REMOTE_AHEAD: noun = "commit" if self.behind == 1 else "commits" return f"Remote has {self.behind} newer {noun}" if self.sync_state == LOCAL_AHEAD: noun = "commit" if self.ahead == 1 else "commits" return f"Local has {self.ahead} unpushed {noun}" if self.sync_state == DIVERGED: return "Local and remote have diverged" return "Unavailable" @property def working_tree_text(self) -> str: if not self.dirty_count: return "Clean" noun = "path" if self.dirty_count == 1 else "paths" return f"{self.dirty_count} changed {noun}" @property def can_pull(self) -> bool: return self.sync_state == REMOTE_AHEAD and self.dirty_count == 0 @property def can_push(self) -> bool: return self.sync_state == LOCAL_AHEAD @property def can_publish(self) -> bool: return ( self.sync_state in {SYNCED, LOCAL_AHEAD} and (self.dirty_count > 0 or bool(self.ahead)) ) class GitRepository: """Narrow wrapper around the installed Git CLI for one working tree.""" def __init__(self, root: Path): self.root = root.resolve() @classmethod def locate(cls, start: Path) -> "GitRepository": start = start.resolve() directory = start if start.is_dir() else start.parent result = _run_git(directory, "rev-parse", "--show-toplevel", check=False) if result.returncode != 0: detail = _error_detail(result) raise GitRepositoryError( f"The launcher is not inside a Git repository. {detail}" ) root = Path(result.stdout.strip()).resolve() try: start.relative_to(root) except ValueError as exc: raise GitRepositoryError( "Git returned a repository that does not contain the launcher." ) from exc return cls(root) def refresh(self, *, allow_prompt: bool = False) -> RepositoryState: """Read local state, safely fetch origin metadata, and compare commits.""" before_fetch = self._read_state(compare_remote=False) if before_fetch.problem: return before_fetch result = self._git( "fetch", "--no-tags", "--prune", "origin", check=False, timeout=60, allow_prompt=allow_prompt, ) if result.returncode != 0: if _is_authentication_failure(result): raise GitAuthenticationError( f"Authentication failed: {self._safe_detail(result)}" ) return replace( before_fetch, sync_state=UNAVAILABLE, ahead=None, behind=None, problem=f"Fetch failed: {self._safe_detail(result)}", ) return self._read_state(compare_remote=True) def pull(self, expected: RepositoryState) -> RepositoryState: current = self.refresh() self._verify_action_state(expected, current) if current.dirty_count: raise GitRepositoryError( "Pull is disabled because the working tree has uncommitted changes. " "Review or commit them before pulling." ) if current.sync_state != REMOTE_AHEAD: raise GitRepositoryError(self._not_actionable_message(current, "pull")) assert current.upstream_branch is not None result = self._git( "pull", "--ff-only", "origin", current.upstream_branch, check=False, timeout=60, ) if result.returncode != 0: raise GitRepositoryError(f"Pull failed: {self._safe_detail(result)}") return self.refresh() def push(self, expected: RepositoryState) -> RepositoryState: current = self.refresh() self._verify_action_state(expected, current) if current.sync_state != LOCAL_AHEAD: raise GitRepositoryError(self._not_actionable_message(current, "push")) assert current.upstream_branch is not None result = self._git( "push", "origin", f"HEAD:refs/heads/{current.upstream_branch}", check=False, timeout=60, allow_prompt=self._origin_is_https(), ) if result.returncode != 0: refreshed = self.refresh() if refreshed.sync_state == DIVERGED: raise GitRepositoryError( "Push failed because the remote changed and the histories now " "diverge. Resolve this manually." ) detail = self._safe_detail(result) if _is_authentication_failure(result): raise GitAuthenticationError(f"Push failed: {detail}") raise GitRepositoryError(f"Push failed: {detail}") return self.refresh() def clear_https_credential(self) -> str: """Reject only the credential associated with this repository's HTTPS host.""" remote_result = self._git("remote", "get-url", "origin", check=False) if remote_result.returncode != 0: raise GitRepositoryError("Cannot determine the origin URL for sign-in.") parsed = urlsplit(remote_result.stdout.strip()) if parsed.scheme != "https" or not parsed.hostname: raise GitRepositoryError( "Automatic sign-in recovery is available only for an HTTPS origin." ) host = parsed.hostname if parsed.port: host = f"{host}:{parsed.port}" credential_input = f"protocol=https\nhost={host}\n\n" result = _run_git( self.root, "credential", "reject", check=False, input_text=credential_input, ) if result.returncode != 0: raise GitRepositoryError( f"Could not clear the rejected credential: {self._safe_detail(result)}" ) return host def _origin_is_https(self) -> bool: result = self._git("remote", "get-url", "origin", check=False) if result.returncode != 0: return False return urlsplit(result.stdout.strip()).scheme == "https" def publishing_changes(self) -> str: """Return every path that a publish operation would stage.""" result = self._git( "status", "--short", "--untracked-files=all", check=True, ) return result.stdout.rstrip() def publish( self, expected: RepositoryState, commit_message: str | None, expected_changes: str, ) -> RepositoryState: """Commit reviewed working-tree changes, then perform the guarded push.""" current = self.refresh() self._verify_action_state(expected, current) current_changes = self.publishing_changes() if current_changes != expected_changes: raise GitRepositoryError( "The working-tree changes changed after confirmation. Review the " "refreshed list before publishing." ) if current.sync_state == DIVERGED: raise GitRepositoryError(self._not_actionable_message(current, "publish")) if current.sync_state == REMOTE_AHEAD: raise GitRepositoryError( "The remote has newer commits. Update the checkout before publishing." ) if current.problem: raise GitRepositoryError(current.problem) if current.dirty_count: message = " ".join((commit_message or "").splitlines()).strip() if not message: raise GitRepositoryError( "A non-empty commit message is required to publish working-tree changes." ) add_result = self._git("add", "--all", check=False) if add_result.returncode != 0: raise GitRepositoryError( f"Could not stage the reviewed changes: {self._safe_detail(add_result)}" ) commit_result = self._git("commit", "-m", message, check=False, timeout=60) if commit_result.returncode != 0: raise GitRepositoryError( "Could not create the publishing commit. The changes remain safe " f"locally and may be staged: {self._safe_detail(commit_result)}" ) current = self.refresh() if current.sync_state != LOCAL_AHEAD: raise GitRepositoryError(self._not_actionable_message(current, "publish")) return self.push(current) def _read_state(self, *, compare_remote: bool) -> RepositoryState: self._verify_root() branch_result = self._git( "symbolic-ref", "--quiet", "--short", "HEAD", check=False ) branch = branch_result.stdout.strip() detached = branch_result.returncode != 0 or not branch if detached: branch = "(detached HEAD)" origin_result = self._git("remote", "get-url", "origin", check=False) raw_origin = origin_result.stdout.strip() if origin_result.returncode == 0 else "" origin_url = _display_url(raw_origin) if raw_origin else None push_result = self._git( "remote", "get-url", "--push", "origin", check=False ) raw_push_url = push_result.stdout.strip() if push_result.returncode == 0 else "" origin_push_url = _display_url(raw_push_url) if raw_push_url else origin_url repository_name = _repository_name(raw_origin) if raw_origin else self.root.name status_result = self._git( "status", "--porcelain=v1", "--untracked-files=normal", check=True ) dirty_count = len(status_result.stdout.splitlines()) if detached: return RepositoryState( self.root, repository_name, branch, origin_url, None, None, dirty_count, None, None, UNAVAILABLE, "The checkout has a detached HEAD. Switch branches manually before syncing.", origin_push_url, ) if not raw_origin: return RepositoryState( self.root, repository_name, branch, None, None, None, dirty_count, None, None, UNAVAILABLE, "No usable origin remote is configured. Configure it manually before syncing.", origin_push_url, ) upstream_result = self._git( "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", check=False, ) upstream = upstream_result.stdout.strip() remote_result = self._git( "config", "--get", f"branch.{branch}.remote", check=False ) merge_result = self._git( "config", "--get", f"branch.{branch}.merge", check=False ) upstream_remote = remote_result.stdout.strip() merge_ref = merge_result.stdout.strip() if ( not upstream or upstream_remote != "origin" or not merge_ref.startswith("refs/heads/") ): return RepositoryState( self.root, repository_name, branch, origin_url, upstream or None, None, dirty_count, None, None, UNAVAILABLE, "The current branch has no usable upstream on origin. Configure its " "tracking branch manually before syncing.", origin_push_url, ) upstream_branch = merge_ref.removeprefix("refs/heads/") expected_upstream = f"origin/{upstream_branch}" if upstream != expected_upstream: return RepositoryState( self.root, repository_name, branch, origin_url, upstream, upstream_branch, dirty_count, None, None, UNAVAILABLE, f"The upstream relationship is ambiguous ({upstream}). Resolve it manually.", origin_push_url, ) if not compare_remote: return RepositoryState( self.root, repository_name, branch, origin_url, upstream, upstream_branch, dirty_count, None, None, UNAVAILABLE, origin_push_url=origin_push_url, ) counts_result = self._git( "rev-list", "--left-right", "--count", f"HEAD...{upstream}", check=False ) if counts_result.returncode != 0: return RepositoryState( self.root, repository_name, branch, origin_url, upstream, upstream_branch, dirty_count, None, None, UNAVAILABLE, f"Cannot compare local and remote commits: {self._safe_detail(counts_result)}", origin_push_url, ) try: ahead, behind = (int(value) for value in counts_result.stdout.split()) except (TypeError, ValueError) as exc: raise GitRepositoryError("Git returned an invalid ahead/behind result.") from exc if ahead and behind: sync_state = DIVERGED elif ahead: sync_state = LOCAL_AHEAD elif behind: sync_state = REMOTE_AHEAD else: sync_state = SYNCED return RepositoryState( self.root, repository_name, branch, origin_url, upstream, upstream_branch, dirty_count, ahead, behind, sync_state, origin_push_url=origin_push_url, ) def _verify_root(self) -> None: result = self._git("rev-parse", "--show-toplevel", check=False) if result.returncode != 0 or Path(result.stdout.strip()).resolve() != self.root: raise GitRepositoryError("The repository containing the launcher has changed.") def _verify_action_state( self, expected: RepositoryState, current: RepositoryState ) -> None: if current.root != expected.root: raise GitRepositoryError("The repository changed; refresh before syncing.") if current.branch != expected.branch: raise GitRepositoryError( f"The current branch changed from {expected.branch} to {current.branch}. " "Review the refreshed status before syncing." ) if ( current.origin_url != expected.origin_url or current.origin_push_url != expected.origin_push_url or current.upstream != expected.upstream ): raise GitRepositoryError( "The remote or upstream configuration changed. Review the refreshed " "status before syncing." ) if current.problem: raise GitRepositoryError(current.problem) @staticmethod def _not_actionable_message(state: RepositoryState, action: str) -> str: if state.sync_state == DIVERGED: return ( "Local and remote histories have diverged. The launcher will not choose " f"a {action} strategy; resolve the histories manually." ) if state.sync_state == SYNCED: return "The repository is already up to date." return f"A safe {action} is not available for the current repository state." def _git( self, *args: str, check: bool, timeout: int = 15, allow_prompt: bool = False, ) -> subprocess.CompletedProcess[str]: result = _run_git( self.root, *args, check=False, timeout=timeout, allow_prompt=allow_prompt, ) if check and result.returncode != 0: raise GitRepositoryError(f"Git failed: {self._safe_detail(result)}") return result def _safe_detail(self, result: subprocess.CompletedProcess[str]) -> str: return re.sub(r"(https?://)[^/\s@]+@", r"\1", _error_detail(result)) def _run_git( cwd: Path, *args: str, check: bool, timeout: int = 15, input_text: str | None = None, allow_prompt: bool = False, ) -> subprocess.CompletedProcess[str]: executable, bundled = _git_executable(cwd) command = [str(executable)] if bundled: command.extend(("-c", "credential.helper=manager")) command.extend(args) options: dict[str, object] = { "cwd": cwd, "capture_output": True, "text": True, "encoding": "utf-8", "errors": "replace", "check": False, "timeout": timeout, "env": { **os.environ, "GIT_TERMINAL_PROMPT": "1" if allow_prompt else "0", }, } if input_text is not None: options["input"] = input_text if os.name == "nt": options["creationflags"] = subprocess.CREATE_NO_WINDOW try: result = subprocess.run(command, **options) except FileNotFoundError as exc: raise GitRepositoryError( "Git is not installed or is not available on PATH." ) from exc except subprocess.TimeoutExpired as exc: raise GitRepositoryError("Git did not finish within the safety timeout.") from exc if check and result.returncode != 0: raise GitRepositoryError(f"Git failed: {_error_detail(result)}") return result def _git_executable(start: Path) -> tuple[Path | str, bool]: """Use the user's Git when available, with bundled PortableGit as fallback.""" system_git = shutil.which("git") if system_git: return system_git, False bundled = start.resolve() / "support" / "PortableGit" / "cmd" / "git.exe" if bundled.is_file(): return bundled, True raise GitRepositoryError( "Git is unavailable. Place PortableGit under support\\PortableGit or install Git." ) def _error_detail(result: subprocess.CompletedProcess[str]) -> str: return (result.stderr.strip() or result.stdout.strip() or "unknown Git error").replace( "\n", " " ) def _is_authentication_failure(result: subprocess.CompletedProcess[str]) -> bool: detail = f"{result.stderr}\n{result.stdout}".casefold() return any( marker in detail for marker in ( "authentication failed", "failed to authenticate", "invalid username or password", "could not read username", "terminal prompts disabled", ) ) def _display_url(value: str) -> str: """Remove HTTP credentials before a remote URL reaches the GUI.""" parsed = urlsplit(value) if parsed.scheme in {"http", "https"} and parsed.hostname: host = parsed.hostname if parsed.port: host = f"{host}:{parsed.port}" return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment)) return value def _repository_name(value: str) -> str: path = urlsplit(value).path if "://" in value else re.split(r"[\\/:]", value)[-1] name = path.rstrip("/").rsplit("/", 1)[-1] return name.removesuffix(".git") or "repository"