032: T045 — git services async (run_blocking for all blocking ops)
_merge.py partially done. Tests still pending.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user