rebase rework

This commit is contained in:
2026-03-09 13:19:06 +03:00
parent 0a108f7db5
commit 8fb9fa15e0
12 changed files with 1010 additions and 21 deletions

View File

@@ -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]

View File

@@ -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.

View File

@@ -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):

View File

@@ -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,