rebase rework
This commit is contained in:
@@ -313,4 +313,128 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
|
||||
assert identity_service.pulled_dashboard_id == 12
|
||||
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_merge_status_returns_service_payload:Function]
|
||||
# @PURPOSE: Ensure merge status route returns service payload as-is.
|
||||
# @PRE: git_service.get_merge_status returns unfinished merge payload.
|
||||
# @POST: Route response contains has_unfinished_merge=True.
|
||||
def test_get_merge_status_returns_service_payload(monkeypatch):
|
||||
class MergeStatusGitService:
|
||||
def get_merge_status(self, dashboard_id: int) -> dict:
|
||||
return {
|
||||
"has_unfinished_merge": True,
|
||||
"repository_path": "/tmp/repo-12",
|
||||
"git_dir": "/tmp/repo-12/.git",
|
||||
"current_branch": "dev",
|
||||
"merge_head": "abc",
|
||||
"merge_message_preview": "merge msg",
|
||||
"conflicts_count": 2,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
|
||||
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
|
||||
|
||||
response = asyncio.run(
|
||||
git_routes.get_merge_status(
|
||||
"dashboard-12",
|
||||
config_manager=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response["has_unfinished_merge"] is True
|
||||
assert response["conflicts_count"] == 2
|
||||
# [/DEF:test_get_merge_status_returns_service_payload:Function]
|
||||
|
||||
|
||||
# [DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
|
||||
# @PURPOSE: Ensure merge resolve route forwards parsed resolutions to service.
|
||||
# @PRE: resolve_data has one file strategy.
|
||||
# @POST: Service receives normalized list and route returns resolved files.
|
||||
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class MergeResolveGitService:
|
||||
def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
|
||||
captured["dashboard_id"] = dashboard_id
|
||||
captured["resolutions"] = resolutions
|
||||
return ["dashboards/a.yaml"]
|
||||
|
||||
class ResolveData:
|
||||
class _Resolution:
|
||||
def dict(self):
|
||||
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
|
||||
|
||||
resolutions = [_Resolution()]
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
|
||||
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
|
||||
|
||||
response = asyncio.run(
|
||||
git_routes.resolve_merge_conflicts(
|
||||
"dashboard-12",
|
||||
ResolveData(),
|
||||
config_manager=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["dashboard_id"] == 12
|
||||
assert captured["resolutions"][0]["resolution"] == "mine"
|
||||
assert response["resolved_files"] == ["dashboards/a.yaml"]
|
||||
# [/DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
|
||||
|
||||
|
||||
# [DEF:test_abort_merge_calls_service_and_returns_result:Function]
|
||||
# @PURPOSE: Ensure abort route delegates to service.
|
||||
# @PRE: Service abort_merge returns aborted status.
|
||||
# @POST: Route returns aborted status.
|
||||
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
|
||||
class AbortGitService:
|
||||
def abort_merge(self, dashboard_id: int):
|
||||
assert dashboard_id == 12
|
||||
return {"status": "aborted"}
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", AbortGitService())
|
||||
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
|
||||
|
||||
response = asyncio.run(
|
||||
git_routes.abort_merge(
|
||||
"dashboard-12",
|
||||
config_manager=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response["status"] == "aborted"
|
||||
# [/DEF:test_abort_merge_calls_service_and_returns_result:Function]
|
||||
|
||||
|
||||
# [DEF:test_continue_merge_passes_message_and_returns_commit:Function]
|
||||
# @PURPOSE: Ensure continue route passes commit message to service.
|
||||
# @PRE: continue_data.message is provided.
|
||||
# @POST: Route returns committed status and hash.
|
||||
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
|
||||
class ContinueGitService:
|
||||
def continue_merge(self, dashboard_id: int, message: str):
|
||||
assert dashboard_id == 12
|
||||
assert message == "Resolve all conflicts"
|
||||
return {"status": "committed", "commit_hash": "abc123"}
|
||||
|
||||
class ContinueData:
|
||||
message = "Resolve all conflicts"
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
|
||||
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
|
||||
|
||||
response = asyncio.run(
|
||||
git_routes.continue_merge(
|
||||
"dashboard-12",
|
||||
ContinueData(),
|
||||
config_manager=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response["status"] == "committed"
|
||||
assert response["commit_hash"] == "abc123"
|
||||
# [/DEF:test_continue_merge_passes_message_and_returns_commit:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
|
||||
|
||||
@@ -30,6 +30,7 @@ from src.api.routes.git_schemas import (
|
||||
GiteaRepoCreateRequest, GiteaRepoSchema,
|
||||
RemoteRepoCreateRequest, RemoteRepoSchema,
|
||||
PromoteRequest, PromoteResponse,
|
||||
MergeStatusSchema, MergeConflictFileSchema, MergeResolveRequest, MergeContinueRequest,
|
||||
)
|
||||
from src.services.git_service import GitService
|
||||
from src.core.superset_client import SupersetClient
|
||||
@@ -825,6 +826,27 @@ async def pull_changes(
|
||||
with belief_scope("pull_changes"):
|
||||
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()
|
||||
config_url = None
|
||||
config_provider = None
|
||||
if db_repo:
|
||||
config_row = db.query(GitServerConfig).filter(GitServerConfig.id == db_repo.config_id).first()
|
||||
if config_row:
|
||||
config_url = config_row.url
|
||||
config_provider = config_row.provider
|
||||
logger.info(
|
||||
"[pull_changes][Action] Route diagnostics dashboard_ref=%s env_id=%s resolved_dashboard_id=%s "
|
||||
"binding_exists=%s binding_local_path=%s binding_remote_url=%s binding_config_id=%s config_provider=%s config_url=%s",
|
||||
dashboard_ref,
|
||||
env_id,
|
||||
dashboard_id,
|
||||
bool(db_repo),
|
||||
(db_repo.local_path if db_repo else None),
|
||||
(db_repo.remote_url if db_repo else None),
|
||||
(db_repo.config_id if db_repo else None),
|
||||
config_provider,
|
||||
config_url,
|
||||
)
|
||||
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
git_service.pull_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
@@ -834,6 +856,122 @@ async def pull_changes(
|
||||
_handle_unexpected_git_route_error("pull_changes", e)
|
||||
# [/DEF:pull_changes:Function]
|
||||
|
||||
# [DEF:get_merge_status:Function]
|
||||
# @PURPOSE: Return unfinished-merge status for repository (web-only recovery support).
|
||||
# @PRE: `dashboard_ref` resolves to a valid dashboard repository.
|
||||
# @POST: Returns merge status payload.
|
||||
@router.get("/repositories/{dashboard_ref}/merge/status", response_model=MergeStatusSchema)
|
||||
async def get_merge_status(
|
||||
dashboard_ref: str,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("get_merge_status"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
return git_service.get_merge_status(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_handle_unexpected_git_route_error("get_merge_status", e)
|
||||
# [/DEF:get_merge_status:Function]
|
||||
|
||||
|
||||
# [DEF:get_merge_conflicts:Function]
|
||||
# @PURPOSE: Return conflicted files with mine/theirs previews for web conflict resolver.
|
||||
# @PRE: `dashboard_ref` resolves to a valid dashboard repository.
|
||||
# @POST: Returns conflict file list.
|
||||
@router.get("/repositories/{dashboard_ref}/merge/conflicts", response_model=List[MergeConflictFileSchema])
|
||||
async def get_merge_conflicts(
|
||||
dashboard_ref: str,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("get_merge_conflicts"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
return git_service.get_merge_conflicts(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_handle_unexpected_git_route_error("get_merge_conflicts", e)
|
||||
# [/DEF:get_merge_conflicts:Function]
|
||||
|
||||
|
||||
# [DEF:resolve_merge_conflicts:Function]
|
||||
# @PURPOSE: Apply mine/theirs/manual conflict resolutions from WebUI and stage files.
|
||||
# @PRE: `dashboard_ref` resolves; request contains at least one resolution item.
|
||||
# @POST: Resolved files are staged in index.
|
||||
@router.post("/repositories/{dashboard_ref}/merge/resolve")
|
||||
async def resolve_merge_conflicts(
|
||||
dashboard_ref: str,
|
||||
resolve_data: MergeResolveRequest,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("resolve_merge_conflicts"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
resolved_files = git_service.resolve_merge_conflicts(
|
||||
dashboard_id,
|
||||
[item.dict() for item in resolve_data.resolutions],
|
||||
)
|
||||
return {"status": "success", "resolved_files": resolved_files}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_handle_unexpected_git_route_error("resolve_merge_conflicts", e)
|
||||
# [/DEF:resolve_merge_conflicts:Function]
|
||||
|
||||
|
||||
# [DEF:abort_merge:Function]
|
||||
# @PURPOSE: Abort unfinished merge from WebUI flow.
|
||||
# @PRE: `dashboard_ref` resolves to repository.
|
||||
# @POST: Merge operation is aborted or reports no active merge.
|
||||
@router.post("/repositories/{dashboard_ref}/merge/abort")
|
||||
async def abort_merge(
|
||||
dashboard_ref: str,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("abort_merge"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
return git_service.abort_merge(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_handle_unexpected_git_route_error("abort_merge", e)
|
||||
# [/DEF:abort_merge:Function]
|
||||
|
||||
|
||||
# [DEF:continue_merge:Function]
|
||||
# @PURPOSE: Finalize unfinished merge from WebUI flow.
|
||||
# @PRE: All conflicts are resolved and staged.
|
||||
# @POST: Merge commit is created.
|
||||
@router.post("/repositories/{dashboard_ref}/merge/continue")
|
||||
async def continue_merge(
|
||||
dashboard_ref: str,
|
||||
continue_data: MergeContinueRequest,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("continue_merge"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
return git_service.continue_merge(dashboard_id, continue_data.message)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_handle_unexpected_git_route_error("continue_merge", e)
|
||||
# [/DEF:continue_merge:Function]
|
||||
|
||||
|
||||
# [DEF:sync_dashboard:Function]
|
||||
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
|
||||
# @PRE: `dashboard_ref` is valid; GitPlugin is available.
|
||||
|
||||
@@ -126,6 +126,42 @@ class ConflictResolution(BaseModel):
|
||||
content: Optional[str] = None
|
||||
# [/DEF:ConflictResolution:Class]
|
||||
|
||||
|
||||
# [DEF:MergeStatusSchema:Class]
|
||||
# @PURPOSE: Schema representing unfinished merge status for repository.
|
||||
class MergeStatusSchema(BaseModel):
|
||||
has_unfinished_merge: bool
|
||||
repository_path: str
|
||||
git_dir: str
|
||||
current_branch: str
|
||||
merge_head: Optional[str] = None
|
||||
merge_message_preview: Optional[str] = None
|
||||
conflicts_count: int = 0
|
||||
# [/DEF:MergeStatusSchema:Class]
|
||||
|
||||
|
||||
# [DEF:MergeConflictFileSchema:Class]
|
||||
# @PURPOSE: Schema describing one conflicted file with optional side snapshots.
|
||||
class MergeConflictFileSchema(BaseModel):
|
||||
file_path: str
|
||||
mine: Optional[str] = None
|
||||
theirs: Optional[str] = None
|
||||
# [/DEF:MergeConflictFileSchema:Class]
|
||||
|
||||
|
||||
# [DEF:MergeResolveRequest:Class]
|
||||
# @PURPOSE: Request schema for resolving one or multiple merge conflicts.
|
||||
class MergeResolveRequest(BaseModel):
|
||||
resolutions: List[ConflictResolution] = Field(default_factory=list)
|
||||
# [/DEF:MergeResolveRequest:Class]
|
||||
|
||||
|
||||
# [DEF:MergeContinueRequest:Class]
|
||||
# @PURPOSE: Request schema for finishing merge with optional explicit commit message.
|
||||
class MergeContinueRequest(BaseModel):
|
||||
message: Optional[str] = None
|
||||
# [/DEF:MergeContinueRequest:Class]
|
||||
|
||||
# [DEF:DeploymentEnvironmentSchema:Class]
|
||||
# @PURPOSE: Schema for representing a target deployment environment.
|
||||
class DeploymentEnvironmentSchema(BaseModel):
|
||||
|
||||
@@ -20,6 +20,7 @@ import shutil
|
||||
from git import Repo
|
||||
from git.exc import GitCommandError
|
||||
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
||||
from git.objects.blob import Blob
|
||||
from fastapi import HTTPException
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
@@ -834,6 +835,204 @@ class GitService:
|
||||
# @PURPOSE: Pull changes from remote.
|
||||
# @PRE: Repository exists and has an 'origin' remote.
|
||||
# @POST: Changes from origin are pulled and merged into the active branch.
|
||||
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:
|
||||
return ""
|
||||
|
||||
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 []
|
||||
|
||||
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": (
|
||||
"В репозитории есть незавершённое слияние. "
|
||||
"Завершите или отмените слияние вручную."
|
||||
),
|
||||
"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": [
|
||||
"Откройте локальный репозиторий по пути repository_path",
|
||||
"Проверьте состояние: git status",
|
||||
"Разрешите конфликты и выполните commit, либо отмените: git merge --abort",
|
||||
"После завершения/отмены слияния повторите Pull из интерфейса",
|
||||
],
|
||||
"manual_commands": [
|
||||
"git status",
|
||||
"git add <resolved-files>",
|
||||
"git commit -m \"resolve merge conflicts\"",
|
||||
"git merge --abort",
|
||||
],
|
||||
}
|
||||
|
||||
def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
|
||||
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),
|
||||
}
|
||||
|
||||
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)
|
||||
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"])
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
|
||||
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"}
|
||||
|
||||
def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
|
||||
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": "Невозможно завершить merge: остались неразрешённые конфликты.",
|
||||
"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}
|
||||
|
||||
def pull_changes(self, dashboard_id: int):
|
||||
with belief_scope("GitService.pull_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -841,23 +1040,46 @@ class GitService:
|
||||
# Check for unfinished merge (MERGE_HEAD exists)
|
||||
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
||||
if os.path.exists(merge_head_path):
|
||||
logger.warning(f"[pull_changes][Action] Unfinished merge detected for dashboard {dashboard_id}")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"В репозитории есть незавершённое слияние. "
|
||||
"Пожалуйста, завершите или отмените слияние вручную: "
|
||||
"1) Разрешите конфликты и выполните коммит, или "
|
||||
"2) Выполните 'git merge --abort' для отмены слияния."
|
||||
),
|
||||
payload = self._build_unfinished_merge_payload(repo)
|
||||
|
||||
logger.warning(
|
||||
"[pull_changes][Action] Unfinished merge detected for dashboard %s "
|
||||
"(repo_path=%s git_dir=%s branch=%s merge_head=%s merge_msg=%s)",
|
||||
dashboard_id,
|
||||
payload["repository_path"],
|
||||
payload["git_dir"],
|
||||
payload["current_branch"],
|
||||
payload["merge_head"],
|
||||
payload["merge_message_preview"],
|
||||
)
|
||||
raise HTTPException(status_code=409, detail=payload)
|
||||
|
||||
try:
|
||||
origin = repo.remote(name='origin')
|
||||
current_branch = repo.active_branch.name
|
||||
try:
|
||||
origin_urls = list(origin.urls)
|
||||
except Exception:
|
||||
origin_urls = []
|
||||
|
||||
logger.info(
|
||||
"[pull_changes][Action] Pull diagnostics dashboard=%s repo_path=%s branch=%s origin_urls=%s",
|
||||
dashboard_id,
|
||||
repo.working_tree_dir,
|
||||
current_branch,
|
||||
origin_urls,
|
||||
)
|
||||
|
||||
origin.fetch(prune=True)
|
||||
remote_ref = f"origin/{current_branch}"
|
||||
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
|
||||
logger.info(
|
||||
"[pull_changes][Action] Pull remote branch check dashboard=%s branch=%s remote_ref=%s exists=%s",
|
||||
dashboard_id,
|
||||
current_branch,
|
||||
remote_ref,
|
||||
has_remote_branch,
|
||||
)
|
||||
if not has_remote_branch:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
|
||||
Reference in New Issue
Block a user