032: T045 — git services async (run_blocking for all blocking ops)

_merge.py partially done. Tests still pending.
This commit is contained in:
2026-06-04 20:36:33 +03:00
parent 5ca1131ba3
commit 27bbf057ec
9 changed files with 70 additions and 64 deletions

View File

@@ -6,6 +6,7 @@
# @RELATION DEPENDS_ON -> [GitRepository]
# @RELATION DEPENDS_ON -> [GitRepository]
# @RATIONALE Extracted 'git_repos' default into DEFAULT_GIT_REPOS_PATH constant. Fixed typo 'repositorys' → 'repositories' with breaking change warning for existing installations.
# @SIDE_EFFECT Async migration T045: blocking git/file operations wrapped via run_blocking executor.
from contextlib import contextmanager
import os
@@ -22,6 +23,7 @@ import httpx
from src.core.database import SessionLocal
from src.core.logger import belief_scope, logger
from src.core.utils.executors import run_blocking
from src.models.config import AppConfigRecord
from src.models.git import GitRepository
@@ -90,13 +92,13 @@ class GitServiceBase:
# @BRIEF Clone repository with PAT and immediately strip credentials from origin remote URL.
# @PRE remote_url is a valid Git URL; pat is provided when required; repo_path is writable.
# @POST Repo cloned at repo_path; origin remote URL has no embedded PAT.
# @SIDE_EFFECT Clones remote repository to local filesystem.
def _clone_with_auth(self, remote_url: str, repo_path: str, pat: str) -> Repo:
# @SIDE_EFFECT Clones remote repository to local filesystem (via run_blocking executor).
async def _clone_with_auth(self, remote_url: str, repo_path: str, pat: str) -> Repo:
auth_url = remote_url
if pat and "://" in remote_url:
proto, rest = remote_url.split("://", 1)
auth_url = f"{proto}://oauth2:{quote(pat, safe='')}@{rest}"
repo = Repo.clone_from(auth_url, repo_path)
repo = await run_blocking(kind='git', fn=Repo.clone_from, auth_url, repo_path)
# Strip PAT from origin URL to prevent credential leakage in .git/config, logs, ps aux
repo.git.remote("set-url", "origin", remote_url)
return repo
@@ -191,8 +193,9 @@ class GitServiceBase:
# @PURPOSE Move legacy repository directory to target path and sync DB metadata.
# @PRE source_path exists.
# @POST Repository content available at target_path.
# @SIDE_EFFECT Filesystem move operations delegated via run_blocking(kind='file', ...).
# @RETURN str
def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
async def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
source_abs = os.path.abspath(source_path)
target_abs = os.path.abspath(target_path)
if source_abs == target_abs:
@@ -200,11 +203,11 @@ class GitServiceBase:
if os.path.exists(target_abs):
logger.reason(f"Target already exists, keeping source path: {target_abs}", extra={"src": "_migrate_repo_directory"})
return source_abs
Path(target_abs).parent.mkdir(parents=True, exist_ok=True)
await run_blocking(kind='file', fn=Path(target_abs).parent.mkdir, parents=True, exist_ok=True)
try:
os.replace(source_abs, target_abs)
await run_blocking(kind='file', fn=os.replace, source_abs, target_abs)
except OSError:
shutil.move(source_abs, target_abs)
await run_blocking(kind='file', fn=shutil.move, source_abs, target_abs)
self._update_repo_local_path(dashboard_id, target_abs)
logger.info(
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
@@ -219,7 +222,7 @@ class GitServiceBase:
# @PRE dashboard_id is an integer.
# @POST Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
# @RETURN str
def _get_repo_path(self, dashboard_id: int, repo_key: str | None = None) -> str:
async def _get_repo_path(self, dashboard_id: int, repo_key: str | None = None) -> str:
with belief_scope("GitService._get_repo_path"):
if dashboard_id is None:
raise ValueError("dashboard_id cannot be None")
@@ -246,13 +249,13 @@ class GitServiceBase:
os.path.abspath(self.base_path) != os.path.abspath(self.legacy_base_path)
and db_path.startswith(os.path.abspath(self.legacy_base_path) + os.sep)
):
return self._migrate_repo_directory(dashboard_id, db_path, target_path)
return await self._migrate_repo_directory(dashboard_id, db_path, target_path)
return db_path
except Exception as e:
logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")
legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
return self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
return await self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
if os.path.exists(target_path):
self._update_repo_local_path(dashboard_id, target_path)
return target_path
@@ -264,31 +267,32 @@ class GitServiceBase:
# @PRE dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST Repository is cloned or opened at the local path. Origin remote URL has no embedded PAT.
# @SIDE_EFFECT Clones remote repository; modifies local filesystem; protected by per-dashboard lock.
# Blocking git/file operations delegated to run_blocking executor.
# @RETURN Repo
def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: str | None = None, default_branch: str | None = None) -> Repo:
async def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: str | None = None, default_branch: str | None = None) -> Repo:
with self._locked(dashboard_id), belief_scope("GitService.init_repo"):
self._ensure_base_path_exists()
repo_path = self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id))
Path(repo_path).parent.mkdir(parents=True, exist_ok=True)
repo_path = await self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id))
await run_blocking(kind='file', fn=Path(repo_path).parent.mkdir, parents=True, exist_ok=True)
if os.path.exists(repo_path):
logger.reason(f"Opening existing repo at {repo_path}", extra={"src": "init_repo"})
try:
repo = Repo(repo_path)
repo = await run_blocking(kind='git', fn=Repo, repo_path)
except (InvalidGitRepositoryError, NoSuchPathError):
logger.reason(f"Existing path is not a Git repository, recreating: {repo_path}", extra={"src": "init_repo"})
stale_path = Path(repo_path)
if stale_path.exists():
shutil.rmtree(stale_path, ignore_errors=True)
await run_blocking(kind='file', fn=shutil.rmtree, stale_path, ignore_errors=True)
if stale_path.exists():
try:
stale_path.unlink()
await run_blocking(kind='file', fn=stale_path.unlink)
except Exception:
pass
repo = self._clone_with_auth(remote_url, repo_path, pat)
repo = await self._clone_with_auth(remote_url, repo_path, pat)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
logger.reason(f"Cloning {remote_url} to {repo_path}", extra={"src": "init_repo"})
repo = self._clone_with_auth(remote_url, repo_path, pat)
repo = await self._clone_with_auth(remote_url, repo_path, pat)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
# endregion init_repo
@@ -298,15 +302,16 @@ class GitServiceBase:
# @PRE dashboard_id is a valid integer.
# @POST Local path is deleted when present and GitRepository row is removed.
# @SIDE_EFFECT Deletes filesystem directory and DB record; protected by per-dashboard lock.
def delete_repo(self, dashboard_id: int) -> None:
# Blocking file operations delegated to run_blocking executor.
async def delete_repo(self, dashboard_id: int) -> None:
with self._locked(dashboard_id), belief_scope("GitService.delete_repo"):
repo_path = self._get_repo_path(dashboard_id)
repo_path = await self._get_repo_path(dashboard_id)
removed_files = False
if os.path.exists(repo_path):
if os.path.isdir(repo_path):
shutil.rmtree(repo_path)
await run_blocking(kind='file', fn=shutil.rmtree, repo_path)
else:
os.remove(repo_path)
await run_blocking(kind='file', fn=os.remove, repo_path)
removed_files = True
session = SessionLocal()
try:
@@ -340,15 +345,16 @@ class GitServiceBase:
# @PURPOSE Get Repo object for a dashboard with concurrent access protection.
# @PRE Repository must exist on disk for the given dashboard_id.
# @POST Returns a GitPython Repo instance for the dashboard.
# @SIDE_EFFECT Blocking Repo() call delegated via run_blocking(kind='git', ...).
# @RETURN Repo
def get_repo(self, dashboard_id: int) -> Repo:
async def get_repo(self, dashboard_id: int) -> Repo:
with self._locked(dashboard_id), belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
repo_path = await self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.error(f"[EXT:method:get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
return await run_blocking(kind='git', fn=Repo, repo_path)
except Exception as e:
logger.error(f"[EXT:method:get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
@@ -359,13 +365,13 @@ class GitServiceBase:
# @PRE dashboard_id repository exists; git_username/git_email may be empty.
# @POST Repository config has user.name and user.email when both identity values are provided.
# @SIDE_EFFECT Writes to repository git config; protected by per-dashboard lock.
def configure_identity(self, dashboard_id: int, git_username: str | None, git_email: str | None) -> None:
async def configure_identity(self, dashboard_id: int, git_username: str | None, git_email: str | None) -> None:
with self._locked(dashboard_id), belief_scope("GitService.configure_identity"):
normalized_username = str(git_username or "").strip()
normalized_email = str(git_email or "").strip()
if not normalized_username or not normalized_email:
return
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
try:
with repo.config_writer(config_level="repository") as config_writer:
config_writer.set_value("user", "name", normalized_username)