Fix git/storage workflows: repos-only page, default dev branch, robust pull/push, and storage path resolution

This commit is contained in:
2026-03-04 19:18:58 +03:00
parent f34f9c1b2e
commit 42def69dcc
10 changed files with 658 additions and 63 deletions

View File

@@ -23,6 +23,7 @@ from src.api.routes.git_schemas import (
BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest,
RepositoryBindingSchema,
RepoStatusBatchRequest, RepoStatusBatchResponse,
GiteaRepoCreateRequest, GiteaRepoSchema,
RemoteRepoCreateRequest, RemoteRepoSchema,
@@ -468,13 +469,15 @@ async def init_repository(
dashboard_id=dashboard_id,
config_id=config.id,
remote_url=init_data.remote_url,
local_path=repo_path
local_path=repo_path,
current_branch="dev",
)
db.add(db_repo)
else:
db_repo.config_id = config.id
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit()
logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}")
@@ -487,6 +490,64 @@ async def init_repository(
_handle_unexpected_git_route_error("init_repository", e)
# [/DEF:init_repository:Function]
# [DEF:get_repository_binding:Function]
# @PURPOSE: Return repository binding with provider metadata for selected dashboard.
# @PRE: `dashboard_ref` resolves to a valid dashboard and repository is initialized.
# @POST: Returns dashboard repository binding and linked provider.
# @PARAM: dashboard_ref (str)
# @RETURN: RepositoryBindingSchema
@router.get("/repositories/{dashboard_ref}", response_model=RepositoryBindingSchema)
async def get_repository_binding(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_repository_binding"):
try:
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
raise HTTPException(status_code=404, detail="Repository not initialized")
config = _get_git_config_or_404(db, db_repo.config_id)
return RepositoryBindingSchema(
dashboard_id=db_repo.dashboard_id,
config_id=db_repo.config_id,
provider=config.provider,
remote_url=db_repo.remote_url,
local_path=db_repo.local_path,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_binding", e)
# [/DEF:get_repository_binding:Function]
# [DEF:delete_repository:Function]
# @PURPOSE: Delete local repository workspace and DB binding for selected dashboard.
# @PRE: `dashboard_ref` resolves to a valid dashboard.
# @POST: Repository files and binding record are removed when present.
# @PARAM: dashboard_ref (str)
# @RETURN: dict
@router.delete("/repositories/{dashboard_ref}")
async def delete_repository(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("delete_repository"):
try:
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
git_service.delete_repo(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("delete_repository", e)
# [/DEF:delete_repository:Function]
# [DEF:get_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for `dashboard_ref` is initialized.

View File

@@ -53,7 +53,7 @@ class GitRepository(Base):
config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False)
remote_url = Column(String(255), nullable=False)
local_path = Column(String(255), nullable=False)
current_branch = Column(String(255), default="main")
current_branch = Column(String(255), default="dev")
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
# [/DEF:GitRepository:Class]

View File

@@ -18,6 +18,8 @@ import httpx
import re
import shutil
from git import Repo
from git.exc import GitCommandError
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from fastapi import HTTPException
from typing import Any, Dict, List, Optional
from datetime import datetime
@@ -167,6 +169,90 @@ class GitService:
return target_abs
# [/DEF:_migrate_repo_directory:Function]
# [DEF:_ensure_gitflow_branches:Function]
# @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.
# @RETURN: None
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"]
local_heads = {head.name: head for head in getattr(repo, "heads", [])}
base_commit = None
try:
base_commit = repo.head.commit
except Exception:
base_commit = None
if "main" in local_heads:
base_commit = local_heads["main"].commit
if base_commit is None:
logger.warning(
f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits"
)
return
if "main" not in local_heads:
local_heads["main"] = repo.create_head("main", base_commit)
logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}")
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
logger.info(
f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
)
try:
origin = repo.remote(name="origin")
except ValueError:
logger.info(
f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation"
)
return
remote_branch_names = set()
try:
origin.fetch()
for ref in origin.refs:
remote_head = getattr(ref, "remote_head", None)
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}")
for branch_name in required_branches:
if branch_name in remote_branch_names:
continue
try:
origin.push(refspec=f"{branch_name}:{branch_name}")
logger.info(
f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
)
except Exception as e:
logger.error(
f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}"
)
raise HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
)
# Keep default working branch on DEV for day-to-day changes.
try:
repo.git.checkout("dev")
logger.info(
f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}"
)
except Exception as e:
logger.warning(
f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}"
)
# [/DEF:_ensure_gitflow_branches:Function]
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
@@ -239,12 +325,74 @@ class GitService:
if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
return Repo(repo_path)
try:
repo = Repo(repo_path)
except (InvalidGitRepositoryError, NoSuchPathError):
logger.warning(
f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}"
)
if os.path.isdir(repo_path):
shutil.rmtree(repo_path)
else:
os.remove(repo_path)
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
return Repo.clone_from(auth_url, repo_path)
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
# [/DEF:init_repo:Function]
# [DEF:delete_repo:Function]
# @PURPOSE: Remove local repository and DB binding for a dashboard.
# @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed.
# @RETURN: None
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: {str(e)}")
finally:
session.close()
# [/DEF:delete_repo:Function]
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id.
@@ -308,7 +456,7 @@ class GitService:
# If everything else failed and list is still empty, add default
if not branches:
branches.append({
"name": "main",
"name": "dev",
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
@@ -428,6 +576,19 @@ class GitService:
if info.flags & info.ERROR:
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "non-fast-forward" in lowered or "rejected" in lowered:
raise HTTPException(
status_code=409,
detail=(
"Push rejected: remote branch contains newer commits. "
"Run Pull first, resolve conflicts if any, then push again."
),
)
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
@@ -443,6 +604,7 @@ class GitService:
try:
origin = repo.remote(name='origin')
current_branch = repo.active_branch.name
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
if not has_remote_branch:
@@ -452,14 +614,24 @@ class GitService:
)
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
fetch_info = origin.pull(current_branch)
for info in fetch_info:
if info.flags & info.ERROR:
logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}")
raise Exception(f"Git pull error for {info.ref}: {info.note}")
# Force deterministic merge strategy for modern git versions.
repo.git.pull("--no-rebase", "origin", current_branch)
except ValueError:
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "conflict" in lowered or "not possible to fast-forward" in lowered:
raise HTTPException(
status_code=409,
detail=(
"Pull requires conflict resolution. Resolve conflicts in repository "
"and repeat operation."
),
)
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
except HTTPException:
raise
except Exception as e:
@@ -805,6 +977,66 @@ class GitService:
)
# [/DEF:delete_gitea_repository:Function]
# [DEF:_gitea_branch_exists:Function]
# @PURPOSE: Check whether a branch exists in Gitea repository.
# @PRE: owner/repo/branch are non-empty.
# @POST: Returns True when branch exists, False when 404.
# @RETURN: bool
async def _gitea_branch_exists(
self,
server_url: str,
pat: str,
owner: str,
repo: str,
branch: str,
) -> bool:
if not owner or not repo or not branch:
return False
endpoint = f"/repos/{owner}/{repo}/branches/{quote(branch, safe='')}"
try:
await self._gitea_request("GET", server_url, pat, endpoint)
return True
except HTTPException as exc:
if exc.status_code == 404:
return False
raise
# [/DEF:_gitea_branch_exists:Function]
# [DEF:_build_gitea_pr_404_detail:Function]
# @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
# @PRE: owner/repo/from_branch/to_branch are provided.
# @POST: Returns specific branch-missing message when detected.
# @RETURN: Optional[str]
async def _build_gitea_pr_404_detail(
self,
server_url: str,
pat: str,
owner: str,
repo: str,
from_branch: str,
to_branch: str,
) -> Optional[str]:
source_exists = await self._gitea_branch_exists(
server_url=server_url,
pat=pat,
owner=owner,
repo=repo,
branch=from_branch,
)
target_exists = await self._gitea_branch_exists(
server_url=server_url,
pat=pat,
owner=owner,
repo=repo,
branch=to_branch,
)
if not source_exists:
return f"Gitea branch not found: source branch '{from_branch}' in {owner}/{repo}"
if not target_exists:
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
return None
# [/DEF:_build_gitea_pr_404_detail:Function]
# [DEF:create_github_repository:Function]
# @PURPOSE: Create repository in GitHub or GitHub Enterprise.
# @PRE: PAT has repository create permission.
@@ -1061,11 +1293,12 @@ class GitService:
"base": to_branch,
"body": description or "",
}
endpoint = f"/repos/{identity['namespace']}/{identity['repo']}/pulls"
endpoint = f"/repos/{identity['owner']}/{identity['repo']}/pulls"
active_server_url = server_url
try:
data = await self._gitea_request(
"POST",
server_url,
active_server_url,
pat,
endpoint,
payload=payload,
@@ -1073,20 +1306,52 @@ class GitService:
except HTTPException as exc:
fallback_url = self._derive_server_url_from_remote(remote_url)
normalized_primary = self._normalize_git_server_url(server_url)
if exc.status_code != 404 or not fallback_url or fallback_url == normalized_primary:
should_retry_with_fallback = (
exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
)
if should_retry_with_fallback:
logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url,
)
active_server_url = fallback_url
try:
data = await self._gitea_request(
"POST",
active_server_url,
pat,
endpoint,
payload=payload,
)
except HTTPException as retry_exc:
if retry_exc.status_code == 404:
branch_detail = await self._build_gitea_pr_404_detail(
server_url=active_server_url,
pat=pat,
owner=identity["owner"],
repo=identity["repo"],
from_branch=from_branch,
to_branch=to_branch,
)
if branch_detail:
raise HTTPException(status_code=400, detail=branch_detail)
raise
else:
if exc.status_code == 404:
branch_detail = await self._build_gitea_pr_404_detail(
server_url=active_server_url,
pat=pat,
owner=identity["owner"],
repo=identity["repo"],
from_branch=from_branch,
to_branch=to_branch,
)
if branch_detail:
raise HTTPException(status_code=400, detail=branch_detail)
raise
logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url,
)
data = await self._gitea_request(
"POST",
fallback_url,
pat,
endpoint,
payload=payload,
)
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating pull request")
return {
"id": data.get("number") or data.get("id"),
"url": data.get("html_url") or data.get("url"),