257 lines
14 KiB
Python
257 lines
14 KiB
Python
# #region GitServiceBranchMixin [C:4] [TYPE Module] [SEMANTICS git, branch, checkout, list, namespace, lock]
|
|
# @LAYER Infrastructure
|
|
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes (all concurrent-safe via per-dashboard locks).
|
|
# @RELATION CALLED_BY -> [GitService]
|
|
|
|
from datetime import UTC, datetime
|
|
import os
|
|
|
|
from fastapi import HTTPException
|
|
from git import Repo
|
|
from git.exc import GitCommandError
|
|
|
|
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 [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"]
|
|
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.reason(
|
|
f"Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits",
|
|
extra={"src": "_ensure_gitflow_branches"},
|
|
)
|
|
return
|
|
if "main" not in local_heads:
|
|
local_heads["main"] = repo.create_head("main", base_commit)
|
|
logger.reason(f"Created local branch main for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
|
|
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.reason(
|
|
f"Created local branch {branch_name} for dashboard {dashboard_id}",
|
|
extra={"src": "_ensure_gitflow_branches"},
|
|
)
|
|
try:
|
|
origin = repo.remote(name="origin")
|
|
except ValueError:
|
|
logger.reason(
|
|
f"Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation",
|
|
extra={"src": "_ensure_gitflow_branches"},
|
|
)
|
|
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.reason(f"Failed to fetch origin refs: {e}", extra={"src": "_ensure_gitflow_branches"})
|
|
for branch_name in required_branches:
|
|
if branch_name in remote_branch_names:
|
|
continue
|
|
try:
|
|
origin.push(refspec=f"{branch_name}:{branch_name}")
|
|
logger.reason(
|
|
f"Pushed branch {branch_name} to origin for dashboard {dashboard_id}",
|
|
extra={"src": "_ensure_gitflow_branches"},
|
|
)
|
|
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: {e!s}",
|
|
)
|
|
# Fix 6: Only checkout if not already on dev
|
|
try:
|
|
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 [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 (no tag refs).
|
|
# @RETURN List[dict]
|
|
def list_branches(self, dashboard_id: int) -> list[dict]:
|
|
with self._locked(dashboard_id), belief_scope("GitService.list_branches"):
|
|
repo = self.get_repo(dashboard_id)
|
|
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.now(UTC)
|
|
})
|
|
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.now(UTC)
|
|
})
|
|
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.now(UTC)
|
|
})
|
|
return branches
|
|
# endregion list_branches
|
|
|
|
# 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 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"})
|
|
readme_path = os.path.join(repo.working_dir, "README.md")
|
|
if not os.path.exists(readme_path):
|
|
with open(readme_path, "w") as f:
|
|
f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
|
|
repo.index.add(["README.md"])
|
|
repo.index.commit("Initial commit")
|
|
try:
|
|
repo.commit(from_branch)
|
|
except Exception:
|
|
logger.reason(f"Source branch {from_branch} not found, using HEAD", extra={"src": "create_branch"})
|
|
from_branch = repo.head
|
|
try:
|
|
new_branch = repo.create_head(name, from_branch)
|
|
return new_branch
|
|
except Exception as e:
|
|
logger.error(f"[create_branch][Coherence:Failed] {e}")
|
|
raise
|
|
# endregion create_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.
|
|
# @SIDE_EFFECT May raise HTTPException(409) if local changes conflict with checkout.
|
|
# @SIDE_EFFECT May raise HTTPException(500) if Git operation fails for other reasons.
|
|
def checkout_branch(self, dashboard_id: int, name: str):
|
|
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"})
|
|
try:
|
|
repo.git.checkout(name)
|
|
except GitCommandError as e:
|
|
stderr = str(e.stderr or "")
|
|
details = str(e)
|
|
lowered = stderr.lower()
|
|
if "local changes" in lowered or "would be overwritten" in lowered:
|
|
# Parse the list of files from stderr
|
|
files = []
|
|
for line in stderr.split("\n"):
|
|
stripped = line.strip()
|
|
if stripped.startswith("\t") and (
|
|
stripped.endswith(".yaml") or "/" in stripped
|
|
):
|
|
files.append(stripped.strip())
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"error_code": "GIT_CHECKOUT_LOCAL_CHANGES",
|
|
"message": (
|
|
f"Невозможно переключиться на ветку '{name}' — "
|
|
f"локальные изменения будут перезаписаны. "
|
|
f"Зафиксируйте или отложите изменения."
|
|
),
|
|
"message_en": (
|
|
f"Cannot checkout branch '{name}' — "
|
|
f"local changes would be overwritten. "
|
|
f"Commit or stash your changes first."
|
|
),
|
|
"files": files,
|
|
"next_steps": [
|
|
"Зафиксируйте изменения (Commit) в текущей ветке перед переключением",
|
|
"Отложите изменения через Stash (вручную: git stash)",
|
|
"Отмените локальные изменения, если они не нужны",
|
|
],
|
|
"next_steps_en": [
|
|
"Commit your changes in the current branch before switching",
|
|
"Stash your changes manually: git stash",
|
|
"Discard local changes if not needed",
|
|
],
|
|
},
|
|
)
|
|
logger.error(f"[checkout_branch][Coherence:Failed] {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Git checkout failed: {details}",
|
|
)
|
|
# endregion checkout_branch
|
|
|
|
# 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 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
|
|
if files:
|
|
logger.reason(f"Staging files: {files}", extra={"src": "commit_changes"})
|
|
repo.index.add(files)
|
|
else:
|
|
logger.reason("Staging all changes", extra={"src": "commit_changes"})
|
|
repo.git.add(A=True)
|
|
repo.index.commit(message)
|
|
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
|
|
# endregion commit_changes
|
|
# #endregion GitServiceBranchMixin
|
|
# #endregion GitServiceBranchMixin
|