From 034c1b653e8197481c56dcec2098d1a189a3b148 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Thu, 13 Aug 2026 19:22:21 -0700 Subject: [PATCH] blog entry --- README.md | 6 +++ scripts/git_repo.py | 89 ++++++++++++++++++++++++++++++++-- scripts/management_launcher.py | 39 +++++++++++++-- tests/test_git_repo.py | 22 +++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fed4fe6..0ff20d7 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,12 @@ it is available. On Windows, they fall back to included Git Credential Manager rather than the first-use helper selector. Authentication is established per workstation through the normal credential manager flow. Never place a username or token in the repository remote URL. +If Gitea rejects a saved credential, the launcher offers to clear only the +credential for the configured HTTPS host and refresh again. Accepting that +prompt permits the normal Git Credential Manager sign-in flow during the +recovery refresh or the next explicit **Commit & Push** operation; it does not +alter working files or local commits. Routine background refreshes remain +non-interactive. ### Local blog editor diff --git a/scripts/git_repo.py b/scripts/git_repo.py index bb3c00b..3c2193d 100644 --- a/scripts/git_repo.py +++ b/scripts/git_repo.py @@ -23,6 +23,10 @@ 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 @@ -100,15 +104,25 @@ class GitRepository: ) from exc return cls(root) - def refresh(self) -> RepositoryState: + 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 + "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, @@ -153,6 +167,7 @@ class GitRepository: f"HEAD:refs/heads/{current.upstream_branch}", check=False, timeout=60, + allow_prompt=self._origin_is_https(), ) if result.returncode != 0: refreshed = self.refresh() @@ -161,9 +176,45 @@ class GitRepository: "Push failed because the remote changed and the histories now " "diverge. Resolve this manually." ) - raise GitRepositoryError(f"Push failed: {self._safe_detail(result)}") + 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( @@ -433,8 +484,15 @@ class GitRepository: *args: str, check: bool, timeout: int = 15, + allow_prompt: bool = False, ) -> subprocess.CompletedProcess[str]: - result = _run_git(self.root, *args, check=False, timeout=timeout) + 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 @@ -448,6 +506,8 @@ def _run_git( *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)] @@ -462,8 +522,13 @@ def _run_git( "errors": "replace", "check": False, "timeout": timeout, - "env": {**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + "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: @@ -498,6 +563,20 @@ def _error_detail(result: subprocess.CompletedProcess[str]) -> str: ) +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) diff --git a/scripts/management_launcher.py b/scripts/management_launcher.py index 3b9fa91..86ced45 100644 --- a/scripts/management_launcher.py +++ b/scripts/management_launcher.py @@ -9,7 +9,12 @@ import sys import threading from typing import Callable -from git_repo import GitRepository, GitRepositoryError, RepositoryState +from git_repo import ( + GitAuthenticationError, + GitRepository, + GitRepositoryError, + RepositoryState, +) SCRIPT_DIR = Path(__file__).resolve().parent @@ -125,9 +130,8 @@ def run_gui() -> None: def refresh(self) -> None: def operation() -> RepositoryState: repository = GitRepository.locate(SCRIPT_DIR) - state = repository.refresh() self.repository = repository - return state + return repository.refresh() self._start_git_task("Refreshing repository status", operation) @@ -209,6 +213,8 @@ def run_gui() -> None: def worker() -> None: try: state = operation() + except GitAuthenticationError as exc: + self.root.after(0, self._finish_authentication_error, label, str(exc)) except (GitRepositoryError, OSError) as exc: self.root.after(0, self._finish_error, label, str(exc)) else: @@ -216,6 +222,33 @@ def run_gui() -> None: threading.Thread(target=worker, daemon=True).start() + def _finish_authentication_error(self, label: str, detail: str) -> None: + self.busy = False + self.state = None + self.details_var.set(detail) + self.values["status"].set("Authentication required") + self._update_buttons() + if self.repository is None: + messagebox.showerror(label, detail, parent=self.root) + return + retry = messagebox.askyesno( + "Git authentication failed", + f"{detail}\n\n" + "Clear only the saved credential for this repository's HTTPS host " + "and open the Git Credential Manager sign-in flow?\n\n" + "Working files and local commits will not be changed.", + parent=self.root, + ) + if not retry: + return + repository = self.repository + + def clear_and_refresh() -> RepositoryState: + repository.clear_https_credential() + return repository.refresh(allow_prompt=True) + + self._start_git_task("Clearing rejected credential and signing in", clear_and_refresh) + def _finish_success(self, state: RepositoryState) -> None: self.busy = False self.state = state diff --git a/tests/test_git_repo.py b/tests/test_git_repo.py index 27317dd..4844ac2 100644 --- a/tests/test_git_repo.py +++ b/tests/test_git_repo.py @@ -8,6 +8,7 @@ import subprocess import sys import unittest import uuid +from subprocess import CompletedProcess sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) @@ -20,6 +21,7 @@ from git_repo import ( # noqa: E402 UNAVAILABLE, GitRepository, GitRepositoryError, + _is_authentication_failure, _display_url, ) @@ -181,6 +183,26 @@ class GitRepositoryTests(unittest.TestCase): _display_url("https://user:secret@git.example.test/owner/repository.git"), ) + def test_authentication_failure_is_detected(self) -> None: + result = CompletedProcess( + ["git", "push"], + 128, + "", + "remote: Failed to authenticate user\nfatal: Authentication failed", + ) + self.assertTrue(_is_authentication_failure(result)) + + def test_clear_https_credential_targets_only_origin_host(self) -> None: + self.git(self.work, "config", "credential.helper", "") + self.git( + self.work, + "remote", + "set-url", + "origin", + "https://git.example.test/team/repository.git", + ) + self.assertEqual("git.example.test", self.repository.clear_https_credential()) + if __name__ == "__main__": unittest.main()