semantic
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user