Warnings fixed: - datetime.utcnow() → datetime.now(UTC) across 48+ files (src/ + tests/) - datetime.utcnow (callback ref) → lambda: datetime.now(UTC) in model fields (18 files) - Pydantic class Config → model_config = ConfigDict(...) (16 files) - Pydantic .dict() → .model_dump() (8 files) - ConfigDict(allow_population_by_field_name=True) → validate_by_name=True - SQLAlchemy declarative_base() import path updated - FastAPI on_event → lifespan context manager (app.py) - Import sorting (ruff I001) auto-fixed across all files - Fixed broken re-export chains that ruff F401 cleanup broke: _validate_bcp47: service.py now imports from dictionary_validation directly job_to_response: _job_routes.py and test imports from service_utils directly fetch_datasource_metadata: restored re-export in service.py - Added missing TranslateJobService import in _job_routes.py (was deleted by F401) - Added ConfigDict(protected_namespaces=()) for DashboardDatasetItem schema field - pytest.ini: replaced deprecated importmode with asyncio_mode All 440 tests pass with zero deprecation warnings.
301 lines
16 KiB
Python
301 lines
16 KiB
Python
# #region GitServiceMergeMixin [C:4] [TYPE Module] [SEMANTICS git, merge, branch, conflict, resolution, lock]
|
|
# @LAYER Infrastructure
|
|
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote (all concurrent-safe via per-dashboard locks).
|
|
# @RELATION CALLED_BY -> [GitService]
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from git import Repo
|
|
from git.exc import GitCommandError
|
|
from git.objects.blob import Blob
|
|
|
|
from src.core.logger import belief_scope, logger
|
|
|
|
|
|
# #region GitServiceMergeMixin [C:3] [TYPE Class]
|
|
# @BRIEF Mixin providing merge operations for GitService.
|
|
class GitServiceMergeMixin:
|
|
# region _read_blob_text [TYPE Function]
|
|
# @PURPOSE: Read text from a Git blob.
|
|
def _read_blob_text(self, blob: Blob) -> str:
|
|
with belief_scope("GitService._read_blob_text"):
|
|
if blob is None:
|
|
return ""
|
|
try:
|
|
return blob.data_stream.read().decode("utf-8", errors="replace")
|
|
except Exception:
|
|
logger.debug("[_read_blob_text] Could not decode blob text")
|
|
return ""
|
|
# endregion _read_blob_text
|
|
|
|
# region _get_unmerged_file_paths [TYPE Function]
|
|
# @PURPOSE: List files with merge conflicts.
|
|
def _get_unmerged_file_paths(self, repo: Repo) -> list[str]:
|
|
with belief_scope("GitService._get_unmerged_file_paths"):
|
|
try:
|
|
return sorted(list(repo.index.unmerged_blobs().keys()))
|
|
except Exception:
|
|
return []
|
|
# endregion _get_unmerged_file_paths
|
|
|
|
# region _build_unfinished_merge_payload [TYPE Function]
|
|
# @PURPOSE: Build payload for unfinished merge state.
|
|
def _build_unfinished_merge_payload(self, repo: Repo) -> dict[str, Any]:
|
|
with belief_scope("GitService._build_unfinished_merge_payload"):
|
|
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
|
merge_head_value = ""
|
|
merge_msg_preview = ""
|
|
current_branch = "unknown"
|
|
try:
|
|
merge_head_value = Path(merge_head_path).read_text(encoding="utf-8").strip()
|
|
except Exception:
|
|
merge_head_value = "<unreadable>"
|
|
try:
|
|
merge_msg_path = os.path.join(repo.git_dir, "MERGE_MSG")
|
|
if os.path.exists(merge_msg_path):
|
|
merge_msg_preview = (
|
|
Path(merge_msg_path).read_text(encoding="utf-8").strip().splitlines()[:1] or [""]
|
|
)[0]
|
|
except Exception:
|
|
merge_msg_preview = "<unreadable>"
|
|
try:
|
|
current_branch = repo.active_branch.name
|
|
except Exception:
|
|
current_branch = "detached_or_unknown"
|
|
conflicts_count = len(self._get_unmerged_file_paths(repo))
|
|
return {
|
|
"error_code": "GIT_UNFINISHED_MERGE",
|
|
"message": (
|
|
"\u0412 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u0435\u0441\u0442\u044c \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043d\u043d\u043e\u0435 \u0441\u043b\u0438\u044f\u043d\u0438\u0435. "
|
|
"\u0417\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0435 \u0438\u043b\u0438 \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u0435 \u0441\u043b\u0438\u044f\u043d\u0438\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e."
|
|
),
|
|
"repository_path": repo.working_tree_dir,
|
|
"git_dir": repo.git_dir,
|
|
"current_branch": current_branch,
|
|
"merge_head": merge_head_value,
|
|
"merge_message_preview": merge_msg_preview,
|
|
"conflicts_count": conflicts_count,
|
|
"next_steps": [
|
|
"\u041e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439 \u043f\u043e \u043f\u0443\u0442\u0438 repository_path",
|
|
"\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435: git status",
|
|
"\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u044b \u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 commit, \u043b\u0438\u0431\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u0435: git merge --abort",
|
|
"\u041f\u043e\u0441\u043b\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f/\u043e\u0442\u043c\u0435\u043d\u044b \u0441\u043b\u0438\u044f\u043d\u0438\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 Pull \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430",
|
|
],
|
|
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
|
|
}
|
|
# endregion _build_unfinished_merge_payload
|
|
|
|
# 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 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"
|
|
try:
|
|
current_branch = repo.active_branch.name
|
|
except Exception:
|
|
current_branch = "detached_or_unknown"
|
|
return {
|
|
"has_unfinished_merge": False,
|
|
"repository_path": repo.working_tree_dir,
|
|
"git_dir": repo.git_dir,
|
|
"current_branch": current_branch,
|
|
"merge_head": None,
|
|
"merge_message_preview": None,
|
|
"conflicts_count": 0,
|
|
}
|
|
payload = self._build_unfinished_merge_payload(repo)
|
|
return {
|
|
"has_unfinished_merge": True,
|
|
"repository_path": payload["repository_path"],
|
|
"git_dir": payload["git_dir"],
|
|
"current_branch": payload["current_branch"],
|
|
"merge_head": payload["merge_head"],
|
|
"merge_message_preview": payload["merge_message_preview"],
|
|
"conflicts_count": int(payload.get("conflicts_count") or 0),
|
|
}
|
|
# endregion get_merge_status
|
|
|
|
# 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 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():
|
|
mine_blob = None
|
|
theirs_blob = None
|
|
for stage, blob in stages:
|
|
if stage == 2:
|
|
mine_blob = blob
|
|
elif stage == 3:
|
|
theirs_blob = blob
|
|
conflicts.append({
|
|
"file_path": file_path,
|
|
"mine": self._read_blob_text(mine_blob) if mine_blob else "",
|
|
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
|
|
})
|
|
return sorted(conflicts, key=lambda item: item["file_path"])
|
|
# endregion get_merge_conflicts
|
|
|
|
# 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 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:
|
|
raise HTTPException(status_code=500, detail="Repository working tree directory is unavailable")
|
|
for item in resolutions or []:
|
|
file_path = str(item.get("file_path") or "").strip()
|
|
strategy = str(item.get("resolution") or "").strip().lower()
|
|
content = item.get("content")
|
|
if not file_path:
|
|
raise HTTPException(status_code=400, detail="resolution.file_path is required")
|
|
if strategy not in {"mine", "theirs", "manual"}:
|
|
raise HTTPException(status_code=400, detail=f"Unsupported resolution strategy: {strategy}")
|
|
if strategy == "mine":
|
|
repo.git.checkout("--ours", "--", file_path)
|
|
elif strategy == "theirs":
|
|
repo.git.checkout("--theirs", "--", file_path)
|
|
else:
|
|
abs_target = os.path.abspath(os.path.join(repo_root, file_path))
|
|
if abs_target != repo_root and not abs_target.startswith(repo_root + os.sep):
|
|
raise HTTPException(status_code=400, detail=f"Invalid conflict file path: {file_path}")
|
|
os.makedirs(os.path.dirname(abs_target), exist_ok=True)
|
|
with open(abs_target, "w", encoding="utf-8") as file_obj:
|
|
file_obj.write(str(content or ""))
|
|
repo.git.add(file_path)
|
|
resolved_files.append(file_path)
|
|
return resolved_files
|
|
# endregion resolve_merge_conflicts
|
|
|
|
# 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 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:
|
|
details = str(e)
|
|
lowered = details.lower()
|
|
if "there is no merge to abort" in lowered or "no merge to abort" in lowered:
|
|
return {"status": "no_merge_in_progress"}
|
|
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
|
|
return {"status": "aborted"}
|
|
# endregion abort_merge
|
|
|
|
# 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 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(
|
|
status_code=409,
|
|
detail={
|
|
"error_code": "GIT_MERGE_CONFLICTS_REMAIN",
|
|
"message": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c merge: \u043e\u0441\u0442\u0430\u043b\u0438\u0441\u044c \u043d\u0435\u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043d\u043d\u044b\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u044b.",
|
|
"unresolved_files": unmerged_files,
|
|
},
|
|
)
|
|
try:
|
|
normalized_message = str(message or "").strip()
|
|
if normalized_message:
|
|
repo.git.commit("-m", normalized_message)
|
|
else:
|
|
repo.git.commit("--no-edit")
|
|
except GitCommandError as e:
|
|
details = str(e)
|
|
lowered = details.lower()
|
|
if "nothing to commit" in lowered:
|
|
return {"status": "already_clean"}
|
|
raise HTTPException(status_code=409, detail=f"Cannot continue merge: {details}")
|
|
commit_hash = ""
|
|
try:
|
|
commit_hash = repo.head.commit.hexsha
|
|
except Exception:
|
|
commit_hash = ""
|
|
return {"status": "committed", "commit_hash": commit_hash}
|
|
# endregion continue_merge
|
|
|
|
# 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. 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 self._locked(dashboard_id), 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")
|
|
|
|
# Remember original branch for restoration in finally
|
|
original_branch = None
|
|
try:
|
|
original_branch = repo.active_branch.name
|
|
except Exception:
|
|
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:
|
|
logger.debug("Could not pull target branch %s before direct promote", target)
|
|
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:
|
|
logger.debug("Could not restore original branch %s after direct promote", original_branch)
|
|
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
|
|
# endregion promote_direct_merge
|
|
# #endregion GitServiceMergeMixin
|
|
# #endregion GitServiceMergeMixin
|