@@ -320,6 +320,12 @@ it is available. On Windows, they fall back to
|
|||||||
included Git Credential Manager rather than the first-use helper selector.
|
included Git Credential Manager rather than the first-use helper selector.
|
||||||
Authentication is established per workstation through the normal credential
|
Authentication is established per workstation through the normal credential
|
||||||
manager flow. Never place a username or token in the repository remote URL.
|
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
|
### Local blog editor
|
||||||
|
|
||||||
|
|||||||
+84
-5
@@ -23,6 +23,10 @@ class GitRepositoryError(Exception):
|
|||||||
"""An expected Git problem that can be displayed directly to a user."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class RepositoryState:
|
class RepositoryState:
|
||||||
root: Path
|
root: Path
|
||||||
@@ -100,15 +104,25 @@ class GitRepository:
|
|||||||
) from exc
|
) from exc
|
||||||
return cls(root)
|
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."""
|
"""Read local state, safely fetch origin metadata, and compare commits."""
|
||||||
before_fetch = self._read_state(compare_remote=False)
|
before_fetch = self._read_state(compare_remote=False)
|
||||||
if before_fetch.problem:
|
if before_fetch.problem:
|
||||||
return before_fetch
|
return before_fetch
|
||||||
result = self._git(
|
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 result.returncode != 0:
|
||||||
|
if _is_authentication_failure(result):
|
||||||
|
raise GitAuthenticationError(
|
||||||
|
f"Authentication failed: {self._safe_detail(result)}"
|
||||||
|
)
|
||||||
return replace(
|
return replace(
|
||||||
before_fetch,
|
before_fetch,
|
||||||
sync_state=UNAVAILABLE,
|
sync_state=UNAVAILABLE,
|
||||||
@@ -153,6 +167,7 @@ class GitRepository:
|
|||||||
f"HEAD:refs/heads/{current.upstream_branch}",
|
f"HEAD:refs/heads/{current.upstream_branch}",
|
||||||
check=False,
|
check=False,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
allow_prompt=self._origin_is_https(),
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
refreshed = self.refresh()
|
refreshed = self.refresh()
|
||||||
@@ -161,9 +176,45 @@ class GitRepository:
|
|||||||
"Push failed because the remote changed and the histories now "
|
"Push failed because the remote changed and the histories now "
|
||||||
"diverge. Resolve this manually."
|
"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()
|
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:
|
def publishing_changes(self) -> str:
|
||||||
"""Return every path that a publish operation would stage."""
|
"""Return every path that a publish operation would stage."""
|
||||||
result = self._git(
|
result = self._git(
|
||||||
@@ -433,8 +484,15 @@ class GitRepository:
|
|||||||
*args: str,
|
*args: str,
|
||||||
check: bool,
|
check: bool,
|
||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
|
allow_prompt: bool = False,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> 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:
|
if check and result.returncode != 0:
|
||||||
raise GitRepositoryError(f"Git failed: {self._safe_detail(result)}")
|
raise GitRepositoryError(f"Git failed: {self._safe_detail(result)}")
|
||||||
return result
|
return result
|
||||||
@@ -448,6 +506,8 @@ def _run_git(
|
|||||||
*args: str,
|
*args: str,
|
||||||
check: bool,
|
check: bool,
|
||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
|
input_text: str | None = None,
|
||||||
|
allow_prompt: bool = False,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
executable, bundled = _git_executable(cwd)
|
executable, bundled = _git_executable(cwd)
|
||||||
command = [str(executable)]
|
command = [str(executable)]
|
||||||
@@ -462,8 +522,13 @@ def _run_git(
|
|||||||
"errors": "replace",
|
"errors": "replace",
|
||||||
"check": False,
|
"check": False,
|
||||||
"timeout": timeout,
|
"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":
|
if os.name == "nt":
|
||||||
options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
try:
|
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:
|
def _display_url(value: str) -> str:
|
||||||
"""Remove HTTP credentials before a remote URL reaches the GUI."""
|
"""Remove HTTP credentials before a remote URL reaches the GUI."""
|
||||||
parsed = urlsplit(value)
|
parsed = urlsplit(value)
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Callable
|
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
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
@@ -125,9 +130,8 @@ def run_gui() -> None:
|
|||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
def operation() -> RepositoryState:
|
def operation() -> RepositoryState:
|
||||||
repository = GitRepository.locate(SCRIPT_DIR)
|
repository = GitRepository.locate(SCRIPT_DIR)
|
||||||
state = repository.refresh()
|
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
return state
|
return repository.refresh()
|
||||||
|
|
||||||
self._start_git_task("Refreshing repository status", operation)
|
self._start_git_task("Refreshing repository status", operation)
|
||||||
|
|
||||||
@@ -209,6 +213,8 @@ def run_gui() -> None:
|
|||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
try:
|
try:
|
||||||
state = operation()
|
state = operation()
|
||||||
|
except GitAuthenticationError as exc:
|
||||||
|
self.root.after(0, self._finish_authentication_error, label, str(exc))
|
||||||
except (GitRepositoryError, OSError) as exc:
|
except (GitRepositoryError, OSError) as exc:
|
||||||
self.root.after(0, self._finish_error, label, str(exc))
|
self.root.after(0, self._finish_error, label, str(exc))
|
||||||
else:
|
else:
|
||||||
@@ -216,6 +222,33 @@ def run_gui() -> None:
|
|||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
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:
|
def _finish_success(self, state: RepositoryState) -> None:
|
||||||
self.busy = False
|
self.busy = False
|
||||||
self.state = state
|
self.state = state
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import uuid
|
import uuid
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
@@ -20,6 +21,7 @@ from git_repo import ( # noqa: E402
|
|||||||
UNAVAILABLE,
|
UNAVAILABLE,
|
||||||
GitRepository,
|
GitRepository,
|
||||||
GitRepositoryError,
|
GitRepositoryError,
|
||||||
|
_is_authentication_failure,
|
||||||
_display_url,
|
_display_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,6 +183,26 @@ class GitRepositoryTests(unittest.TestCase):
|
|||||||
_display_url("https://user:[email protected]/owner/repository.git"),
|
_display_url("https://user:[email protected]/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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user