This commit is contained in:
2026-05-20 23:54:53 +03:00
parent a43886106e
commit 68740cd9ed
167 changed files with 10620 additions and 29041 deletions

View File

@@ -1,23 +1,24 @@
# #region GitServiceBase [C:4] [TYPE Module] [SEMANTICS git, repository, clone, base, mixin, lock, http]
# @LAYER: Infra
# @LAYER Infra
# @BRIEF Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), identity configuration, concurrent locking, and shared HTTP client pool.
# @RELATION INHERITED_BY -> [GitService]
# @RELATION DEPENDS_ON -> [SessionLocal]
# @RELATION DEPENDS_ON -> [AppConfigRecord]
# @RELATION DEPENDS_ON -> [GitRepository]
# @RELATION DEPENDS_ON -> [GitRepository]
# @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.
from contextlib import contextmanager
import os
from pathlib import Path
import re
import shutil
import threading
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import HTTPException
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
import httpx
from src.core.database import SessionLocal
from src.core.logger import belief_scope, logger
@@ -32,9 +33,9 @@ from src.models.git import GitRepository
# @SIDE_EFFECT Creates HTTP connection pool (max_keepalive=20, max_connections=100).
class GitServiceBase:
# region GitService_init [C:4] [TYPE Function]
# @PURPOSE: Initializes the GitService with a base path, concurrent access locks, and shared HTTP client.
# @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists; _lock_registry, _http_client ready.
# @PURPOSE Initializes the GitService with a base path, concurrent access locks, and shared HTTP client.
# @PRE base_path is a valid string path.
# @POST GitService is initialized; base_path directory exists; _lock_registry, _http_client ready.
def __init__(self, base_path: str = "git_repos"):
with belief_scope("GitService.__init__"):
backend_root = Path(__file__).parents[3]
@@ -60,8 +61,8 @@ class GitServiceBase:
# region _get_lock [C:2] [TYPE Function] [SEMANTICS lock,concurrency]
# @BRIEF Get or create a per-dashboard reentrant lock for thread-safe GitPython access.
# @PRE: dashboard_id is a valid integer.
# @POST: Returns a threading.RLock unique to the given dashboard_id.
# @PRE dashboard_id is a valid integer.
# @POST Returns a threading.RLock unique to the given dashboard_id.
def _get_lock(self, dashboard_id: int) -> threading.RLock:
with self._reg_lock:
if dashboard_id not in self._lock_registry:
@@ -71,8 +72,8 @@ class GitServiceBase:
# region _locked [C:2] [TYPE Function] [SEMANTICS lock,context,concurrency]
# @BRIEF Context manager that acquires the per-dashboard reentrant lock for the duration of the block.
# @PRE: dashboard_id is a valid integer.
# @POST: Lock is acquired on enter, released on exit.
# @PRE dashboard_id is a valid integer.
# @POST Lock is acquired on enter, released on exit.
@contextmanager
def _locked(self, dashboard_id: int):
if self._closed:
@@ -87,8 +88,8 @@ class GitServiceBase:
# region _clone_with_auth [C:2] [TYPE Function] [SEMANTICS git,clone,auth,security]
# @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.
# @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:
auth_url = remote_url
@@ -102,9 +103,9 @@ class GitServiceBase:
# endregion _clone_with_auth
# region _ensure_base_path_exists [TYPE Function]
# @PURPOSE: Ensure the repositories root directory exists and is a directory.
# @PRE: self.base_path is resolved to filesystem path.
# @POST: self.base_path exists as directory or raises ValueError.
# @PURPOSE Ensure the repositories root directory exists and is a directory.
# @PRE self.base_path is resolved to filesystem path.
# @POST self.base_path exists as directory or raises ValueError.
def _ensure_base_path_exists(self) -> None:
base = Path(self.base_path)
if base.exists() and not base.is_dir():
@@ -119,10 +120,10 @@ class GitServiceBase:
# endregion _ensure_base_path_exists
# region _resolve_base_path [TYPE Function]
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
# @PRE: base_path is a string path.
# @POST: Returns absolute path for Git repositories root.
# @RETURN: str
# @PURPOSE Resolve base repository directory from explicit argument or global storage settings.
# @PRE base_path is a string path.
# @POST Returns absolute path for Git repositories root.
# @RETURN str
def _resolve_base_path(self, base_path: str) -> str:
backend_root = Path(__file__).parents[3]
fallback_path = str((backend_root / base_path).resolve())
@@ -144,7 +145,7 @@ class GitServiceBase:
root = Path(root_path)
if not root.is_absolute():
root = (project_root / root).resolve()
repo_root = Path(repo_path) if repo_path else Path("repositorys")
repo_root = Path(repo_path) if repo_path else Path("repositories")
if repo_root.is_absolute():
return str(repo_root.resolve())
return str((root / repo_root).resolve())
@@ -154,10 +155,10 @@ class GitServiceBase:
# endregion _resolve_base_path
# region _normalize_repo_key [TYPE Function]
# @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE: repo_key can be None/empty.
# @POST: Returns normalized non-empty key.
# @RETURN: str
# @PURPOSE Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE repo_key can be None/empty.
# @POST Returns normalized non-empty key.
# @RETURN str
def _normalize_repo_key(self, repo_key: str | None) -> str:
raw_key = str(repo_key or "").strip().lower()
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
@@ -165,9 +166,9 @@ class GitServiceBase:
# endregion _normalize_repo_key
# region _update_repo_local_path [TYPE Function]
# @PURPOSE: Persist repository local_path in GitRepository table when record exists.
# @PRE: dashboard_id is valid integer.
# @POST: local_path is updated for existing record.
# @PURPOSE Persist repository local_path in GitRepository table when record exists.
# @PRE dashboard_id is valid integer.
# @POST local_path is updated for existing record.
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
try:
session = SessionLocal()
@@ -187,10 +188,10 @@ class GitServiceBase:
# endregion _update_repo_local_path
# region _migrate_repo_directory [TYPE Function]
# @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
# @PRE: source_path exists.
# @POST: Repository content available at target_path.
# @RETURN: str
# @PURPOSE Move legacy repository directory to target path and sync DB metadata.
# @PRE source_path exists.
# @POST Repository content available at target_path.
# @RETURN str
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)
@@ -212,12 +213,12 @@ class GitServiceBase:
# endregion _migrate_repo_directory
# region _get_repo_path [TYPE Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
# @PRE: dashboard_id is an integer.
# @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
# @RETURN: str
# @PURPOSE Resolves the local filesystem path for a dashboard's repository.
# @PARAM dashboard_id (int)
# @PARAM repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
# @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:
with belief_scope("GitService._get_repo_path"):
if dashboard_id is None:
@@ -258,12 +259,12 @@ class GitServiceBase:
# endregion _get_repo_path
# region init_repo [C:4] [TYPE Function] [SEMANTICS git,clone,init,lock]
# @PURPOSE: Initialize or clone a repository for a dashboard with concurrent access protection.
# @PARAM: dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
# @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.
# @PURPOSE Initialize or clone a repository for a dashboard with concurrent access protection.
# @PARAM dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
# @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.
# @RETURN: Repo
# @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:
with self._locked(dashboard_id):
with belief_scope("GitService.init_repo"):
@@ -294,9 +295,9 @@ class GitServiceBase:
# endregion init_repo
# region delete_repo [C:4] [TYPE Function] [SEMANTICS git,delete,lock]
# @PURPOSE: Remove local repository and DB binding for a dashboard with concurrent access protection.
# @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed.
# @PURPOSE Remove local repository and DB binding for a dashboard with concurrent access protection.
# @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:
with self._locked(dashboard_id):
@@ -338,10 +339,10 @@ class GitServiceBase:
# endregion delete_repo
# region get_repo [C:4] [TYPE Function] [SEMANTICS git,open,lock]
# @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.
# @RETURN: Repo
# @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.
# @RETURN Repo
def get_repo(self, dashboard_id: int) -> Repo:
with self._locked(dashboard_id):
with belief_scope("GitService.get_repo"):
@@ -357,9 +358,9 @@ class GitServiceBase:
# endregion get_repo
# region configure_identity [C:4] [TYPE Function] [SEMANTICS git,identity,lock]
# @PURPOSE: Configure repository-local Git committer identity with concurrent access protection.
# @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.
# @PURPOSE Configure repository-local Git committer identity with concurrent access protection.
# @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:
with self._locked(dashboard_id):
@@ -381,8 +382,8 @@ class GitServiceBase:
# region close [C:2] [TYPE Function] [SEMANTICS http,cleanup,shutdown]
# @BRIEF Gracefully close the shared HTTP client (connection pool).
# @PRE: _http_client is initialized.
# @POST: HTTP connections closed.
# @PRE _http_client is initialized.
# @POST HTTP connections closed.
async def close(self):
with self._close_lock:
if self._closed:
@@ -392,3 +393,5 @@ class GitServiceBase:
# endregion close
# #endregion GitServiceBase
# #endregion GitServiceBase
# #endregion GitServiceBase
# #endregion GitServiceBase