diff --git a/backend/src/api/routes/git/_helpers.py b/backend/src/api/routes/git/_helpers.py index 3e47b16c..7f2ac9d5 100644 --- a/backend/src/api/routes/git/_helpers.py +++ b/backend/src/api/routes/git/_helpers.py @@ -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 diff --git a/backend/src/api/routes/git/_repo_lifecycle_routes.py b/backend/src/api/routes/git/_repo_lifecycle_routes.py index 7eb1e692..8af9ec11 100644 --- a/backend/src/api/routes/git/_repo_lifecycle_routes.py +++ b/backend/src/api/routes/git/_repo_lifecycle_routes.py @@ -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, diff --git a/backend/src/api/routes/git/_repo_operations_routes.py b/backend/src/api/routes/git/_repo_operations_routes.py index 2b534dab..a57b4bb0 100644 --- a/backend/src/api/routes/git/_repo_operations_routes.py +++ b/backend/src/api/routes/git/_repo_operations_routes.py @@ -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(), diff --git a/backend/src/api/routes/git/_repo_routes.py b/backend/src/api/routes/git/_repo_routes.py index bc1609b8..4f142913 100644 --- a/backend/src/api/routes/git/_repo_routes.py +++ b/backend/src/api/routes/git/_repo_routes.py @@ -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 ) diff --git a/backend/src/services/git/_base.py b/backend/src/services/git/_base.py index bed67fbb..9fffb668 100644 --- a/backend/src/services/git/_base.py +++ b/backend/src/services/git/_base.py @@ -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/. # @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) diff --git a/backend/src/services/git/_branch.py b/backend/src/services/git/_branch.py index a107abc2..964b98ad 100644 --- a/backend/src/services/git/_branch.py +++ b/backend/src/services/git/_branch.py @@ -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 diff --git a/backend/src/services/git/_merge.py b/backend/src/services/git/_merge.py index 08d67fc7..0ef84692 100644 --- a/backend/src/services/git/_merge.py +++ b/backend/src/services/git/_merge.py @@ -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(): diff --git a/backend/src/services/git/_status.py b/backend/src/services/git/_status.py index 185c92af..e14f90c9 100644 --- a/backend/src/services/git/_status.py +++ b/backend/src/services/git/_status.py @@ -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: diff --git a/backend/src/services/git/_sync.py b/backend/src/services/git/_sync.py index 3ac3f1f3..359c6c28 100644 --- a/backend/src/services/git/_sync.py +++ b/backend/src/services/git/_sync.py @@ -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)