# #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 = "" 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 = "" 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 ", '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