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

@@ -55,9 +55,9 @@ def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> Non
# @BRIEF Resolve repository status for one dashboard with graceful NO_REPO semantics.
# @PRE `dashboard_id` is a valid integer.
# @POST Returns standard status payload or `NO_REPO` payload when repository path is absent.
def _resolve_repository_status(dashboard_id: int) -> dict:
async def _resolve_repository_status(dashboard_id: int) -> dict:
git_service = get_git_service()
repo_path = git_service._get_repo_path(dashboard_id)
repo_path = await git_service._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.reason(
f"Repository is not initialized for dashboard {dashboard_id}",
@@ -310,7 +310,7 @@ def _resolve_current_user_git_identity(
# #region _apply_git_identity_from_profile [C:2] [TYPE Function]
# @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations.
def _apply_git_identity_from_profile(
async def _apply_git_identity_from_profile(
dashboard_id: int,
db: Session,
current_user: User | None,
@@ -320,11 +320,11 @@ def _apply_git_identity_from_profile(
return
git_service = get_git_service()
configure_identity = getattr(git_service, "configure_identity", None)
if not callable(configure_identity):
configure_identity_fn = getattr(git_service, "configure_identity", None)
if not callable(configure_identity_fn):
return
git_username, git_email = identity
configure_identity(dashboard_id, git_username, git_email)
await configure_identity_fn(dashboard_id, git_username, git_email)
# #endregion _apply_git_identity_from_profile
# #endregion GitHelpers

View File

@@ -118,7 +118,7 @@ async def promote_dashboard(
to_branch,
reason,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
result = _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,

View File

@@ -48,7 +48,7 @@ async def commit_changes(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.commit_changes(
dashboard_id, commit_data.message, commit_data.files
)
@@ -138,7 +138,7 @@ async def pull_changes(
f"config_provider={config_provider} config_url={config_url}",
extra={"src": "pull_changes"},
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.pull_changes(dashboard_id)
return {"status": "success"}
except HTTPException:
@@ -164,7 +164,7 @@ async def get_repository_status(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _resolve_repository_status(dashboard_id)
return await _resolve_repository_status(dashboard_id)
except HTTPException:
raise
except Exception as e:
@@ -191,7 +191,7 @@ async def get_repository_status_batch(
statuses = {}
for dashboard_id in dashboard_ids:
try:
statuses[str(dashboard_id)] = _resolve_repository_status(dashboard_id)
statuses[str(dashboard_id)] = await _resolve_repository_status(dashboard_id)
except HTTPException:
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),

View File

@@ -63,7 +63,7 @@ async def init_repository(
f"Initializing repo for dashboard {dashboard_id}",
extra={"src": "init_repository"},
)
_gs.init_repo(
await _gs.init_repo(
dashboard_id,
init_data.remote_url,
config.pat,
@@ -71,7 +71,7 @@ async def init_repository(
default_branch=config.default_branch,
)
repo_path = _gs._get_repo_path(dashboard_id, repo_key=repo_key)
repo_path = await _gs._get_repo_path(dashboard_id, repo_key=repo_key)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
@@ -166,7 +166,7 @@ async def delete_repository(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.delete_repo(dashboard_id)
await _gs.delete_repo(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
@@ -220,7 +220,7 @@ async def create_branch(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.create_branch(
dashboard_id, branch_data.name, branch_data.from_branch
)

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)

View File

@@ -99,9 +99,9 @@ class GitServiceBranchMixin:
# @PRE Repository for dashboard_id exists.
# @POST Returns a list of branch metadata dictionaries (no tag refs).
# @RETURN List[dict]
def list_branches(self, dashboard_id: int) -> list[dict]:
async def list_branches(self, dashboard_id: int) -> list[dict]:
with self._locked(dashboard_id), belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
logger.reason(f"Listing branches for {dashboard_id}. Refs: {repo.refs}", extra={"src": "list_branches"})
branches = []
for ref in repo.refs:
@@ -144,10 +144,10 @@ class GitServiceBranchMixin:
# @PARAM from_branch (str) - Source branch.
# @PRE Repository exists; name is valid; from_branch exists or repo is empty.
# @POST A new branch is created in the repository.
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
async def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with self._locked(dashboard_id):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
logger.reason(f"Creating branch {name} from {from_branch}", extra={"src": "create_branch"})
if not repo.heads and not repo.remotes:
logger.reason("Repository is empty. Creating initial commit to enable branching.", extra={"src": "create_branch"})
@@ -176,10 +176,10 @@ class GitServiceBranchMixin:
# @POST The repository working directory is updated to the specified branch.
# @SIDE_EFFECT May raise HTTPException(409) if local changes conflict with checkout.
# @SIDE_EFFECT May raise HTTPException(500) if Git operation fails for other reasons.
def checkout_branch(self, dashboard_id: int, name: str):
async def checkout_branch(self, dashboard_id: int, name: str):
with self._locked(dashboard_id):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
logger.reason(f"Checking out branch {name}", extra={"src": "checkout_branch"})
try:
repo.git.checkout(name)
@@ -236,10 +236,10 @@ class GitServiceBranchMixin:
# @PARAM files (List[str]) - Optional list of specific files to stage.
# @PRE Repository exists and has changes (dirty) or files are specified.
# @POST Changes are staged and a new commit is created.
def commit_changes(self, dashboard_id: int, message: str, files: list[str] = None):
async def commit_changes(self, dashboard_id: int, message: str, files: list[str] = None):
with self._locked(dashboard_id):
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
if not repo.is_dirty(untracked_files=True) and not files:
logger.reason(f"No changes to commit for dashboard {dashboard_id}", extra={"src": "commit_changes"})
return

View File

@@ -90,10 +90,10 @@ class GitServiceMergeMixin:
# region get_merge_status [C:4] [TYPE Function] [SEMANTICS git,merge,status,lock]
# @PURPOSE: Get current merge status for a dashboard repository (concurrent-safe).
def get_merge_status(self, dashboard_id: int) -> dict[str, Any]:
async def get_merge_status(self, dashboard_id: int) -> dict[str, Any]:
with self._locked(dashboard_id):
with belief_scope("GitService.get_merge_status"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if not os.path.exists(merge_head_path):
current_branch = "unknown"
@@ -124,10 +124,10 @@ class GitServiceMergeMixin:
# region get_merge_conflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,list,lock]
# @PURPOSE: List all files with conflicts and their contents (concurrent-safe).
def get_merge_conflicts(self, dashboard_id: int) -> list[dict[str, Any]]:
async def get_merge_conflicts(self, dashboard_id: int) -> list[dict[str, Any]]:
with self._locked(dashboard_id):
with belief_scope("GitService.get_merge_conflicts"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
conflicts = []
unmerged = repo.index.unmerged_blobs()
for file_path, stages in unmerged.items():

View File

@@ -55,10 +55,10 @@ class GitServiceStatusMixin:
# @PRE Repository for dashboard_id exists.
# @POST Returns a dictionary representing the Git status.
# @RETURN dict
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
with self._locked(dashboard_id):
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
has_commits = False
try:
repo.head.commit
@@ -118,10 +118,10 @@ class GitServiceStatusMixin:
# @PRE Repository for dashboard_id exists.
# @POST Returns the diff text as a string.
# @RETURN str
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
async def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with self._locked(dashboard_id):
with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
diff_args = []
if staged:
diff_args.append("--staged")
@@ -136,10 +136,10 @@ class GitServiceStatusMixin:
# @PRE Repository for dashboard_id exists.
# @POST Returns a list of dictionaries for each commit in history.
# @RETURN List[dict]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> list[dict]:
async def get_commit_history(self, dashboard_id: int, limit: int = 50) -> list[dict]:
with self._locked(dashboard_id):
with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
commits = []
try:
if not repo.heads and not repo.remotes:

View File

@@ -21,10 +21,10 @@ class GitServiceSyncMixin:
# @PURPOSE: Push local commits to remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
def push_changes(self, dashboard_id: int):
async def push_changes(self, dashboard_id: int):
with self._locked(dashboard_id):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
if not repo.heads:
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
@@ -115,10 +115,10 @@ class GitServiceSyncMixin:
# @PURPOSE: Pull changes from remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch.
def pull_changes(self, dashboard_id: int):
async def pull_changes(self, dashboard_id: int):
with self._locked(dashboard_id):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
repo = await self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if os.path.exists(merge_head_path):
payload = self._build_unfinished_merge_payload(repo)