595 lines
21 KiB
Python
595 lines
21 KiB
Python
#!/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"
|