fix(health): suppress 404 when health monitor disabled + fix audit test assertion
- Sidebar.svelte: defer healthStore.refresh() until feature flags confirm health_monitor is enabled; skip entirely when disabled - health.js: remove throw error from catch block — log and return null instead, preventing 'Uncaught (in promise)' on expected 404 - test_report_audit_immutability.py: fix mock assertions — audit_service uses logger.reason/reflect/explore, not logger.info - HealthStore and Sidebar now produce zero network noise when FEATURES__HEALTH_MONITOR=false
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceBase [C:3] [TYPE Module] [SEMANTICS git, repository, clone, base, mixin]
|
||||
# #region GitServiceBase [C:4] [TYPE Module] [SEMANTICS git, repository, clone, base, mixin, lock, http]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), and identity configuration.
|
||||
# @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]
|
||||
@@ -9,8 +9,12 @@
|
||||
import os
|
||||
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
|
||||
@@ -21,14 +25,16 @@ from src.models.config import AppConfigRecord
|
||||
from src.models.git import GitRepository
|
||||
|
||||
|
||||
# #region GitServiceBase [C:3] [TYPE Class]
|
||||
# @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, and identity.
|
||||
# #region GitServiceBase [C:4] [TYPE Class]
|
||||
# @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, identity, concurrent locking, and shared HTTP client.
|
||||
# @PRE base_path is a valid string path.
|
||||
# @POST GitService is initialized; base_path directory exists; shared AsyncClient ready; lock registry empty.
|
||||
# @SIDE_EFFECT Creates HTTP connection pool (max_keepalive=20, max_connections=100).
|
||||
class GitServiceBase:
|
||||
# region GitService_init [TYPE Function]
|
||||
# @PURPOSE: Initializes the GitService with a base path for repositories.
|
||||
# @PARAM: base_path (str) - Root directory for all Git clones.
|
||||
# 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.
|
||||
# @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]
|
||||
@@ -36,8 +42,65 @@ class GitServiceBase:
|
||||
self._uses_default_base_path = base_path == "git_repos"
|
||||
self.base_path = self._resolve_base_path(base_path)
|
||||
self._ensure_base_path_exists()
|
||||
|
||||
# Fix 3: Per-dashboard reentrant lock registry for concurrent access protection
|
||||
self._lock_registry: dict[int, threading.RLock] = {}
|
||||
self._reg_lock = threading.Lock()
|
||||
|
||||
# Fix: close() race condition protection
|
||||
self._closed = False
|
||||
self._close_lock = threading.Lock()
|
||||
|
||||
# Fix 5: Shared httpx.AsyncClient with connection pooling
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100),
|
||||
)
|
||||
# endregion GitService_init
|
||||
|
||||
# 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.
|
||||
def _get_lock(self, dashboard_id: int) -> threading.RLock:
|
||||
with self._reg_lock:
|
||||
if dashboard_id not in self._lock_registry:
|
||||
self._lock_registry[dashboard_id] = threading.RLock()
|
||||
return self._lock_registry[dashboard_id]
|
||||
# endregion _get_lock
|
||||
|
||||
# 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.
|
||||
@contextmanager
|
||||
def _locked(self, dashboard_id: int):
|
||||
if self._closed:
|
||||
raise RuntimeError("GitService is closed")
|
||||
lock = self._get_lock(dashboard_id)
|
||||
lock.acquire()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
# endregion _locked
|
||||
|
||||
# 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.
|
||||
# @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
|
||||
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)
|
||||
# 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
|
||||
# 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.
|
||||
@@ -194,124 +257,138 @@ class GitServiceBase:
|
||||
return target_path
|
||||
# endregion _get_repo_path
|
||||
|
||||
# region init_repo [TYPE Function]
|
||||
# @PURPOSE: Initialize or clone a repository for a dashboard.
|
||||
# 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.
|
||||
# @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
|
||||
def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: str | None = None) -> Repo:
|
||||
with 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)
|
||||
if pat and "://" in remote_url:
|
||||
proto, rest = remote_url.split("://", 1)
|
||||
auth_url = f"{proto}://oauth2:{pat}@{rest}"
|
||||
else:
|
||||
auth_url = remote_url
|
||||
if os.path.exists(repo_path):
|
||||
logger.reason(f"Opening existing repo at {repo_path}", extra={"src": "init_repo"})
|
||||
try:
|
||||
repo = 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)
|
||||
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"):
|
||||
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)
|
||||
if os.path.exists(repo_path):
|
||||
logger.reason(f"Opening existing repo at {repo_path}", extra={"src": "init_repo"})
|
||||
try:
|
||||
repo = 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():
|
||||
try:
|
||||
stale_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
repo = Repo.clone_from(auth_url, repo_path)
|
||||
shutil.rmtree(stale_path, ignore_errors=True)
|
||||
if stale_path.exists():
|
||||
try:
|
||||
stale_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
repo = 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)
|
||||
self._ensure_gitflow_branches(repo, dashboard_id)
|
||||
return repo
|
||||
logger.reason(f"Cloning {remote_url} to {repo_path}", extra={"src": "init_repo"})
|
||||
repo = Repo.clone_from(auth_url, repo_path)
|
||||
self._ensure_gitflow_branches(repo, dashboard_id)
|
||||
return repo
|
||||
# endregion init_repo
|
||||
|
||||
# region delete_repo [TYPE Function]
|
||||
# @PURPOSE: Remove local repository and DB binding for a dashboard.
|
||||
# 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.
|
||||
# @SIDE_EFFECT Deletes filesystem directory and DB record; protected by per-dashboard lock.
|
||||
def delete_repo(self, dashboard_id: int) -> None:
|
||||
with belief_scope("GitService.delete_repo"):
|
||||
repo_path = 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)
|
||||
else:
|
||||
os.remove(repo_path)
|
||||
removed_files = True
|
||||
session = SessionLocal()
|
||||
try:
|
||||
db_repo = (
|
||||
session.query(GitRepository)
|
||||
.filter(GitRepository.dashboard_id == int(dashboard_id))
|
||||
.first()
|
||||
)
|
||||
if db_repo:
|
||||
session.delete(db_repo)
|
||||
session.commit()
|
||||
return
|
||||
if removed_files:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Repository for dashboard {dashboard_id} not found",
|
||||
)
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e!s}")
|
||||
finally:
|
||||
session.close()
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.delete_repo"):
|
||||
repo_path = 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)
|
||||
else:
|
||||
os.remove(repo_path)
|
||||
removed_files = True
|
||||
session = SessionLocal()
|
||||
try:
|
||||
db_repo = (
|
||||
session.query(GitRepository)
|
||||
.filter(GitRepository.dashboard_id == int(dashboard_id))
|
||||
.first()
|
||||
)
|
||||
if db_repo:
|
||||
session.delete(db_repo)
|
||||
session.commit()
|
||||
return
|
||||
if removed_files:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Repository for dashboard {dashboard_id} not found",
|
||||
)
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e!s}")
|
||||
finally:
|
||||
session.close()
|
||||
# endregion delete_repo
|
||||
|
||||
# region get_repo [TYPE Function]
|
||||
# @PURPOSE: Get Repo object for a dashboard.
|
||||
# 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
|
||||
def get_repo(self, dashboard_id: int) -> Repo:
|
||||
with belief_scope("GitService.get_repo"):
|
||||
repo_path = self._get_repo_path(dashboard_id)
|
||||
if not os.path.exists(repo_path):
|
||||
logger.error(f"[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)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_repo"):
|
||||
repo_path = self._get_repo_path(dashboard_id)
|
||||
if not os.path.exists(repo_path):
|
||||
logger.error(f"[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)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
|
||||
# endregion get_repo
|
||||
|
||||
# region configure_identity [TYPE Function]
|
||||
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
|
||||
# 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.
|
||||
# @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 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)
|
||||
try:
|
||||
with repo.config_writer(config_level="repository") as config_writer:
|
||||
config_writer.set_value("user", "name", normalized_username)
|
||||
config_writer.set_value("user", "email", normalized_email)
|
||||
logger.reason(f"Applied repository-local git identity for dashboard {dashboard_id}", extra={"src": "configure_identity"})
|
||||
except Exception as e:
|
||||
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {e!s}")
|
||||
with self._locked(dashboard_id):
|
||||
with 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)
|
||||
try:
|
||||
with repo.config_writer(config_level="repository") as config_writer:
|
||||
config_writer.set_value("user", "name", normalized_username)
|
||||
config_writer.set_value("user", "email", normalized_email)
|
||||
logger.reason(f"Applied repository-local git identity for dashboard {dashboard_id}", extra={"src": "configure_identity"})
|
||||
except Exception as e:
|
||||
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {e!s}")
|
||||
# endregion configure_identity
|
||||
|
||||
# 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.
|
||||
async def close(self):
|
||||
with self._close_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
await self._http_client.aclose()
|
||||
# endregion close
|
||||
# #endregion GitServiceBase
|
||||
# #endregion GitServiceBase
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceBranchMixin [C:3] [TYPE Module] [SEMANTICS git, branch, checkout, list, namespace]
|
||||
# #region GitServiceBranchMixin [C:4] [TYPE Module] [SEMANTICS git, branch, checkout, list, namespace, lock]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes.
|
||||
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes (all concurrent-safe via per-dashboard locks).
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import os
|
||||
@@ -15,10 +15,11 @@ from src.core.logger import belief_scope, logger
|
||||
# #region GitServiceBranchMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing branch and commit operations for GitService.
|
||||
class GitServiceBranchMixin:
|
||||
# region _ensure_gitflow_branches [TYPE Function]
|
||||
# region _ensure_gitflow_branches [C:4] [TYPE Function] [SEMANTICS git,gitflow,branch,lock]
|
||||
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
|
||||
# @PRE: repo is a valid GitPython Repo instance.
|
||||
# @POST: main, dev, preprod are available in local repository and pushed to origin when available.
|
||||
# Active branch unchanged (no spurious checkout).
|
||||
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
|
||||
with belief_scope("GitService._ensure_gitflow_branches"):
|
||||
required_branches = ["main", "dev", "preprod"]
|
||||
@@ -79,62 +80,74 @@ class GitServiceBranchMixin:
|
||||
status_code=500,
|
||||
detail=f"Failed to create default branch '{branch_name}' on remote: {e!s}",
|
||||
)
|
||||
# Fix 6: Only checkout if not already on dev
|
||||
try:
|
||||
repo.git.checkout("dev")
|
||||
logger.reason(f"Checked out default branch dev for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
|
||||
except Exception as e:
|
||||
logger.reason(f"Could not checkout dev branch for dashboard {dashboard_id}: {e}", extra={"src": "_ensure_gitflow_branches"})
|
||||
current_branch = repo.active_branch.name
|
||||
except Exception:
|
||||
current_branch = None
|
||||
if current_branch != "dev":
|
||||
try:
|
||||
repo.git.checkout("dev")
|
||||
logger.reason(f"Checked out default branch dev for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
|
||||
except Exception as e:
|
||||
logger.reason(f"Could not checkout dev branch for dashboard {dashboard_id}: {e}", extra={"src": "_ensure_gitflow_branches"})
|
||||
# endregion _ensure_gitflow_branches
|
||||
|
||||
# region list_branches [TYPE Function]
|
||||
# @PURPOSE: List all branches for a dashboard's repository.
|
||||
# region list_branches [C:4] [TYPE Function] [SEMANTICS git,branch,list,lock]
|
||||
# @PURPOSE: List all branches (excluding tags) for a dashboard's repository, concurrent-safe.
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
# @POST: Returns a list of branch metadata dictionaries.
|
||||
# @POST: Returns a list of branch metadata dictionaries (no tag refs).
|
||||
# @RETURN: List[dict]
|
||||
def list_branches(self, dashboard_id: int) -> list[dict]:
|
||||
with belief_scope("GitService.list_branches"):
|
||||
repo = 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:
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.list_branches"):
|
||||
repo = 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:
|
||||
try:
|
||||
# Fix 8: Skip tag refs
|
||||
ref_name = str(ref.name)
|
||||
if ref_name.startswith('refs/tags/'):
|
||||
continue
|
||||
name = ref_name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
|
||||
if any(b['name'] == name for b in branches):
|
||||
continue
|
||||
branches.append({
|
||||
"name": name,
|
||||
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
|
||||
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
|
||||
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.reason(f"Skipping ref {ref}: {e}", extra={"src": "list_branches"})
|
||||
try:
|
||||
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
|
||||
if any(b['name'] == name for b in branches):
|
||||
continue
|
||||
branches.append({
|
||||
"name": name,
|
||||
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
|
||||
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
|
||||
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
|
||||
})
|
||||
active_name = repo.active_branch.name
|
||||
if not any(b['name'] == active_name for b in branches):
|
||||
branches.append({
|
||||
"name": active_name, "commit_hash": "0000000",
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.reason(f"Skipping ref {ref}: {e}", extra={"src": "list_branches"})
|
||||
try:
|
||||
active_name = repo.active_branch.name
|
||||
if not any(b['name'] == active_name for b in branches):
|
||||
branches.append({
|
||||
"name": active_name, "commit_hash": "0000000",
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.reason(f"Could not determine active branch: {e}", extra={"src": "list_branches"})
|
||||
if not branches:
|
||||
branches.append({
|
||||
"name": "dev", "commit_hash": "0000000",
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
return branches
|
||||
logger.reason(f"Could not determine active branch: {e}", extra={"src": "list_branches"})
|
||||
if not branches:
|
||||
branches.append({
|
||||
"name": "dev", "commit_hash": "0000000",
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
return branches
|
||||
# endregion list_branches
|
||||
|
||||
# region create_branch [TYPE Function]
|
||||
# @PURPOSE: Create a new branch from an existing one.
|
||||
# region create_branch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock]
|
||||
# @PURPOSE: Create a new branch from an existing one (concurrent-safe).
|
||||
# @PARAM: name (str) - New branch name.
|
||||
# @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"):
|
||||
with belief_scope("GitService.create_branch"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.create_branch"):
|
||||
repo = 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"})
|
||||
@@ -157,26 +170,28 @@ class GitServiceBranchMixin:
|
||||
raise
|
||||
# endregion create_branch
|
||||
|
||||
# region checkout_branch [TYPE Function]
|
||||
# @PURPOSE: Switch to a specific branch.
|
||||
# region checkout_branch [C:4] [TYPE Function] [SEMANTICS git,branch,checkout,lock]
|
||||
# @PURPOSE: Switch to a specific branch (concurrent-safe).
|
||||
# @PRE: Repository exists and the specified branch name exists.
|
||||
# @POST: The repository working directory is updated to the specified branch.
|
||||
def checkout_branch(self, dashboard_id: int, name: str):
|
||||
with belief_scope("GitService.checkout_branch"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.checkout_branch"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
logger.reason(f"Checking out branch {name}", extra={"src": "checkout_branch"})
|
||||
repo.git.checkout(name)
|
||||
# endregion checkout_branch
|
||||
|
||||
# region commit_changes [TYPE Function]
|
||||
# @PURPOSE: Stage and commit changes.
|
||||
# region commit_changes [C:4] [TYPE Function] [SEMANTICS git,commit,stage,lock]
|
||||
# @PURPOSE: Stage and commit changes (concurrent-safe).
|
||||
# @PARAM: message (str) - Commit message.
|
||||
# @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):
|
||||
with belief_scope("GitService.commit_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.commit_changes"):
|
||||
repo = 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceGiteaMixin [C:3] [TYPE Module] [SEMANTICS git, gitea, api, remote, connection]
|
||||
# #region GitServiceGiteaMixin [C:4] [TYPE Module] [SEMANTICS git, gitea, api, remote, connection, http_pool]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation.
|
||||
# @BRIEF Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation. Uses shared self._http_client for connection pooling.
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
from typing import Any
|
||||
@@ -35,24 +35,23 @@ class GitServiceGiteaMixin:
|
||||
return False
|
||||
pat = pat.strip()
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
if provider == GitProvider.GITHUB:
|
||||
headers = {"Authorization": f"token {pat}"}
|
||||
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
|
||||
resp = await client.get(api_url, headers=headers)
|
||||
elif provider == GitProvider.GITLAB:
|
||||
headers = {"PRIVATE-TOKEN": pat}
|
||||
api_url = f"{url.rstrip('/')}/api/v4/user"
|
||||
resp = await client.get(api_url, headers=headers)
|
||||
elif provider == GitProvider.GITEA:
|
||||
headers = {"Authorization": f"token {pat}"}
|
||||
api_url = f"{url.rstrip('/')}/api/v1/user"
|
||||
resp = await client.get(api_url, headers=headers)
|
||||
else:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
|
||||
return resp.status_code == 200
|
||||
if provider == GitProvider.GITHUB:
|
||||
headers = {"Authorization": f"token {pat}"}
|
||||
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
|
||||
resp = await self._http_client.get(api_url, headers=headers)
|
||||
elif provider == GitProvider.GITLAB:
|
||||
headers = {"PRIVATE-TOKEN": pat}
|
||||
api_url = f"{url.rstrip('/')}/api/v4/user"
|
||||
resp = await self._http_client.get(api_url, headers=headers)
|
||||
elif provider == GitProvider.GITEA:
|
||||
headers = {"Authorization": f"token {pat}"}
|
||||
api_url = f"{url.rstrip('/')}/api/v1/user"
|
||||
resp = await self._http_client.get(api_url, headers=headers)
|
||||
else:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
|
||||
return False
|
||||
@@ -87,8 +86,7 @@ class GitServiceGiteaMixin:
|
||||
url = f"{base_url}/api/v1{endpoint}"
|
||||
headers = self._gitea_headers(pat)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.request(method=method, url=url, headers=headers, json=payload)
|
||||
response = await self._http_client.request(method=method, url=url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {e!s}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceMergeMixin [C:3] [TYPE Module] [SEMANTICS git, merge, branch, conflict, resolution]
|
||||
# #region GitServiceMergeMixin [C:4] [TYPE Module] [SEMANTICS git, merge, branch, conflict, resolution, lock]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote.
|
||||
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote (all concurrent-safe via per-dashboard locks).
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import os
|
||||
@@ -87,11 +87,12 @@ class GitServiceMergeMixin:
|
||||
}
|
||||
# endregion _build_unfinished_merge_payload
|
||||
|
||||
# region get_merge_status [TYPE Function]
|
||||
# @PURPOSE: Get current merge status for a dashboard repository.
|
||||
# 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]:
|
||||
with belief_scope("GitService.get_merge_status"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_merge_status"):
|
||||
repo = 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"
|
||||
@@ -120,11 +121,12 @@ class GitServiceMergeMixin:
|
||||
}
|
||||
# endregion get_merge_status
|
||||
|
||||
# region get_merge_conflicts [TYPE Function]
|
||||
# @PURPOSE: List all files with conflicts and their contents.
|
||||
# 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]]:
|
||||
with belief_scope("GitService.get_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
conflicts = []
|
||||
unmerged = repo.index.unmerged_blobs()
|
||||
for file_path, stages in unmerged.items():
|
||||
@@ -143,11 +145,12 @@ class GitServiceMergeMixin:
|
||||
return sorted(conflicts, key=lambda item: item["file_path"])
|
||||
# endregion get_merge_conflicts
|
||||
|
||||
# region resolve_merge_conflicts [TYPE Function]
|
||||
# @PURPOSE: Resolve conflicts using specified strategy.
|
||||
# region resolve_merge_conflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,resolve,lock]
|
||||
# @PURPOSE: Resolve conflicts using specified strategy (concurrent-safe).
|
||||
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: list[dict[str, Any]]) -> list[str]:
|
||||
with belief_scope("GitService.resolve_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.resolve_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
resolved_files: list[str] = []
|
||||
repo_root = os.path.abspath(str(repo.working_tree_dir or ""))
|
||||
if not repo_root:
|
||||
@@ -176,11 +179,12 @@ class GitServiceMergeMixin:
|
||||
return resolved_files
|
||||
# endregion resolve_merge_conflicts
|
||||
|
||||
# region abort_merge [TYPE Function]
|
||||
# @PURPOSE: Abort ongoing merge.
|
||||
# region abort_merge [C:4] [TYPE Function] [SEMANTICS git,merge,abort,lock]
|
||||
# @PURPOSE: Abort ongoing merge (concurrent-safe).
|
||||
def abort_merge(self, dashboard_id: int) -> dict[str, Any]:
|
||||
with belief_scope("GitService.abort_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.abort_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
try:
|
||||
repo.git.merge("--abort")
|
||||
except GitCommandError as e:
|
||||
@@ -192,11 +196,12 @@ class GitServiceMergeMixin:
|
||||
return {"status": "aborted"}
|
||||
# endregion abort_merge
|
||||
|
||||
# region continue_merge [TYPE Function]
|
||||
# @PURPOSE: Finalize merge after conflict resolution.
|
||||
# region continue_merge [C:4] [TYPE Function] [SEMANTICS git,merge,continue,lock]
|
||||
# @PURPOSE: Finalize merge after conflict resolution (concurrent-safe).
|
||||
def continue_merge(self, dashboard_id: int, message: str | None = None) -> dict[str, Any]:
|
||||
with belief_scope("GitService.continue_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.continue_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
unmerged_files = self._get_unmerged_file_paths(repo)
|
||||
if unmerged_files:
|
||||
raise HTTPException(
|
||||
@@ -227,51 +232,69 @@ class GitServiceMergeMixin:
|
||||
return {"status": "committed", "commit_hash": commit_hash}
|
||||
# endregion continue_merge
|
||||
|
||||
# region promote_direct_merge [TYPE Function]
|
||||
# @PURPOSE: Perform direct merge between branches in local repo and push target branch.
|
||||
# region promote_direct_merge [C:4] [TYPE Function] [SEMANTICS git,merge,promote,branch,isolation]
|
||||
# @PURPOSE: Perform direct merge between branches with branch isolation — original branch restored on error.
|
||||
# @PRE: Repository exists and both branches are valid.
|
||||
# @POST: Target branch contains merged changes from source branch.
|
||||
# @POST: Target branch contains merged changes from source branch. Active branch restored to original.
|
||||
# Merge survives locally even if push fails (partial success).
|
||||
# @SIDE_EFFECT: Changes local branch state during merge; restores original branch in finally block.
|
||||
# @RETURN: Dict[str, Any]
|
||||
def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> dict[str, Any]:
|
||||
with belief_scope("GitService.promote_direct_merge"):
|
||||
if not from_branch or not to_branch:
|
||||
raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
|
||||
repo = self.get_repo(dashboard_id)
|
||||
source = from_branch.strip()
|
||||
target = to_branch.strip()
|
||||
if source == target:
|
||||
raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")
|
||||
try:
|
||||
origin = repo.remote(name="origin")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
try:
|
||||
origin.fetch()
|
||||
if source not in [head.name for head in repo.heads]:
|
||||
if f"origin/{source}" in [ref.name for ref in repo.refs]:
|
||||
repo.git.checkout("-b", source, f"origin/{source}")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
|
||||
if target in [head.name for head in repo.heads]:
|
||||
repo.git.checkout(target)
|
||||
elif f"origin/{target}" in [ref.name for ref in repo.refs]:
|
||||
repo.git.checkout("-b", target, f"origin/{target}")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.promote_direct_merge"):
|
||||
if not from_branch or not to_branch:
|
||||
raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
|
||||
repo = self.get_repo(dashboard_id)
|
||||
source = from_branch.strip()
|
||||
target = to_branch.strip()
|
||||
if source == target:
|
||||
raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")
|
||||
try:
|
||||
origin.pull(target)
|
||||
origin = repo.remote(name="origin")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
|
||||
# Remember original branch for restoration in finally
|
||||
original_branch = None
|
||||
try:
|
||||
original_branch = repo.active_branch.name
|
||||
except Exception:
|
||||
pass
|
||||
repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
|
||||
origin.push(refspec=f"{target}:{target}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
message = str(e)
|
||||
if "CONFLICT" in message.upper():
|
||||
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
|
||||
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
|
||||
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
|
||||
original_branch = None
|
||||
|
||||
try:
|
||||
origin.fetch()
|
||||
if source not in [head.name for head in repo.heads]:
|
||||
if f"origin/{source}" in [ref.name for ref in repo.refs]:
|
||||
repo.git.checkout("-b", source, f"origin/{source}")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
|
||||
if target in [head.name for head in repo.heads]:
|
||||
repo.git.checkout(target)
|
||||
elif f"origin/{target}" in [ref.name for ref in repo.refs]:
|
||||
repo.git.checkout("-b", target, f"origin/{target}")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
|
||||
try:
|
||||
origin.pull(target)
|
||||
except Exception:
|
||||
pass
|
||||
repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
|
||||
origin.push(refspec=f"{target}:{target}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
message = str(e)
|
||||
if "CONFLICT" in message.upper():
|
||||
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
|
||||
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
|
||||
finally:
|
||||
# Restore original branch even if merge/push partially failed
|
||||
if original_branch:
|
||||
try:
|
||||
repo.git.checkout(original_branch)
|
||||
except Exception:
|
||||
pass
|
||||
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
|
||||
# endregion promote_direct_merge
|
||||
# #endregion GitServiceMergeMixin
|
||||
# #endregion GitServiceMergeMixin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceRemoteMixin [C:3] [TYPE Module] [SEMANTICS git, provider, github, remote, url]
|
||||
# #region GitServiceRemoteMixin [C:4] [TYPE Module] [SEMANTICS git, provider, github, remote, url, http_pool]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation.
|
||||
# @BRIEF GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation. Uses shared self._http_client for connection pooling.
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
from typing import Any
|
||||
@@ -38,8 +38,7 @@ class GitServiceGithubMixin:
|
||||
if default_branch:
|
||||
payload["default_branch"] = default_branch
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(api_url, headers=headers, json=payload)
|
||||
response = await self._http_client.post(api_url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {e!s}")
|
||||
if response.status_code >= 400:
|
||||
@@ -78,8 +77,7 @@ class GitServiceGithubMixin:
|
||||
"body": description or "", "draft": bool(draft),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(api_url, headers=headers, json=payload)
|
||||
response = await self._http_client.post(api_url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {e!s}")
|
||||
if response.status_code >= 400:
|
||||
@@ -118,8 +116,7 @@ class GitServiceGitlabMixin:
|
||||
if default_branch:
|
||||
payload["default_branch"] = default_branch
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(api_url, headers=headers, json=payload)
|
||||
response = await self._http_client.post(api_url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {e!s}")
|
||||
if response.status_code >= 400:
|
||||
@@ -162,8 +159,7 @@ class GitServiceGitlabMixin:
|
||||
"description": description or "", "remove_source_branch": bool(remove_source_branch),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(api_url, headers=headers, json=payload)
|
||||
response = await self._http_client.post(api_url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {e!s}")
|
||||
if response.status_code >= 400:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceStatusMixin [C:3] [TYPE Module] [SEMANTICS git, status, diff, log, history]
|
||||
# #region GitServiceStatusMixin [C:4] [TYPE Module] [SEMANTICS git, status, diff, log, history, lock]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Status, diff, and commit history operations for GitService using git status --porcelain to avoid --cached flag issues.
|
||||
# @BRIEF Status, diff, and commit history operations for GitService (all concurrent-safe via per-dashboard locks).
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
from datetime import datetime
|
||||
@@ -50,14 +50,15 @@ class GitServiceStatusMixin:
|
||||
return staged, modified, untracked
|
||||
# endregion _parse_status_porcelain
|
||||
|
||||
# region get_status [TYPE Function]
|
||||
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
|
||||
# region get_status [C:4] [TYPE Function] [SEMANTICS git,status,lock]
|
||||
# @PURPOSE: Get current repository status (concurrent-safe).
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
# @POST: Returns a dictionary representing the Git status.
|
||||
# @RETURN: dict
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
with belief_scope("GitService.get_status"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_status"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
has_commits = False
|
||||
try:
|
||||
repo.head.commit
|
||||
@@ -110,16 +111,17 @@ class GitServiceStatusMixin:
|
||||
}
|
||||
# endregion get_status
|
||||
|
||||
# region get_diff [TYPE Function]
|
||||
# @PURPOSE: Generate diff for a file or the whole repository.
|
||||
# region get_diff [C:4] [TYPE Function] [SEMANTICS git,diff,lock]
|
||||
# @PURPOSE: Generate diff for a file or the whole repository (concurrent-safe).
|
||||
# @PARAM: file_path (str) - Optional specific file.
|
||||
# @PARAM: staged (bool) - Whether to show staged changes.
|
||||
# @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:
|
||||
with belief_scope("GitService.get_diff"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_diff"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
diff_args = []
|
||||
if staged:
|
||||
diff_args.append("--staged")
|
||||
@@ -128,15 +130,16 @@ class GitServiceStatusMixin:
|
||||
return repo.git.diff(*diff_args)
|
||||
# endregion get_diff
|
||||
|
||||
# region get_commit_history [TYPE Function]
|
||||
# @PURPOSE: Retrieve commit history for a repository.
|
||||
# region get_commit_history [C:4] [TYPE Function] [SEMANTICS git,history,lock]
|
||||
# @PURPOSE: Retrieve commit history for a repository (concurrent-safe).
|
||||
# @PARAM: limit (int) - Max number of commits to return.
|
||||
# @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]:
|
||||
with belief_scope("GitService.get_commit_history"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.get_commit_history"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
commits = []
|
||||
try:
|
||||
if not repo.heads and not repo.remotes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceSyncMixin [C:3] [TYPE Module] [SEMANTICS git, sync, push, pull, remote]
|
||||
# #region GitServiceSyncMixin [C:4] [TYPE Module] [SEMANTICS git, sync, push, pull, remote, lock]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Push and pull operations for GitService with origin host auto-alignment.
|
||||
# @BRIEF Push and pull operations for GitService with origin host auto-alignment (concurrent-safe).
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
|
||||
|
||||
@@ -17,13 +17,14 @@ from src.models.git import GitRepository, GitServerConfig
|
||||
# #region GitServiceSyncMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing push and pull operations with origin host alignment.
|
||||
class GitServiceSyncMixin:
|
||||
# region push_changes [TYPE Function]
|
||||
# @PURPOSE: Push local commits to remote.
|
||||
# region push_changes [C:4] [TYPE Function] [SEMANTICS git,push,lock]
|
||||
# @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):
|
||||
with belief_scope("GitService.push_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.push_changes"):
|
||||
repo = 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
|
||||
@@ -110,13 +111,14 @@ class GitServiceSyncMixin:
|
||||
raise HTTPException(status_code=500, detail=f"Git push failed: {e!s}")
|
||||
# endregion push_changes
|
||||
|
||||
# region pull_changes [TYPE Function]
|
||||
# @PURPOSE: Pull changes from remote.
|
||||
# region pull_changes [C:4] [TYPE Function] [SEMANTICS git,pull,lock]
|
||||
# @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):
|
||||
with belief_scope("GitService.pull_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
with self._locked(dashboard_id):
|
||||
with belief_scope("GitService.pull_changes"):
|
||||
repo = 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)
|
||||
|
||||
Reference in New Issue
Block a user