From 309dfdba867a0164227e83db2c796b46b5321cf3 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 9 Mar 2026 13:19:06 +0300 Subject: [PATCH] rebase rework --- .../routes/__tests__/test_git_status_route.py | 124 ++++++++ backend/src/api/routes/git.py | 138 ++++++++ backend/src/api/routes/git_schemas.py | 36 +++ backend/src/services/git_service.py | 240 +++++++++++++- .../components/git/ConflictResolver.svelte | 3 +- frontend/src/components/git/GitManager.svelte | 301 +++++++++++++++++- ...nager.unfinished_merge.integration.test.js | 43 +++ frontend/src/lib/api.js | 30 +- frontend/src/lib/i18n/locales/en.json | 12 + frontend/src/lib/i18n/locales/ru.json | 12 + frontend/src/services/gitService.js | 75 ++++- specs/011-git-integration-dashboard/tasks.md | 17 +- 12 files changed, 1010 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/git/__tests__/git_manager.unfinished_merge.integration.test.js diff --git a/backend/src/api/routes/__tests__/test_git_status_route.py b/backend/src/api/routes/__tests__/test_git_status_route.py index d5e94941..3dfbd59f 100644 --- a/backend/src/api/routes/__tests__/test_git_status_route.py +++ b/backend/src/api/routes/__tests__/test_git_status_route.py @@ -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] diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index 4fdc8cc5..b4f0d66c 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -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. diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index 95f95a9e..042f0197 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -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): diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index bf0e8b43..bce34ace 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -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 = "" + 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": ( + "В репозитории есть незавершённое слияние. " + "Завершите или отмените слияние вручную." + ), + "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 ", + "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, diff --git a/frontend/src/components/git/ConflictResolver.svelte b/frontend/src/components/git/ConflictResolver.svelte index 3bdede65..73bdc152 100644 --- a/frontend/src/components/git/ConflictResolver.svelte +++ b/frontend/src/components/git/ConflictResolver.svelte @@ -15,10 +15,9 @@ // [/SECTION] // [SECTION: PROPS] - /** @type {Array<{file_path: string, mine: string, theirs: string}>} */ let { conflicts = [], - show = false, + show = $bindable(false), } = $props(); // [/SECTION] diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte index e0c5a78d..08642f8a 100644 --- a/frontend/src/components/git/GitManager.svelte +++ b/frontend/src/components/git/GitManager.svelte @@ -18,6 +18,7 @@ import { Button, Card, PageHeader, Select, Input } from '../../lib/ui'; import BranchSelector from './BranchSelector.svelte'; import DeploymentModal from './DeploymentModal.svelte'; + import ConflictResolver from './ConflictResolver.svelte'; // [/SECTION] // [SECTION: PROPS] @@ -63,6 +64,15 @@ let repositoryProvider = $state(''); let repositoryBindingRemoteUrl = $state(''); let repositoryConfigUrl = $state(''); + let showUnfinishedMergeDialog = $state(false); + let unfinishedMergeContext = $state(null); + let copyingUnfinishedMergeCommands = $state(false); + let mergeRecoveryLoading = $state(false); + let mergeConflicts = $state([]); + let showConflictResolver = $state(false); + let mergeResolveInProgress = $state(false); + let mergeAbortInProgress = $state(false); + let mergeContinueInProgress = $state(false); // [/SECTION] const hasWorkspaceChanges = $derived.by(() => { @@ -302,6 +312,181 @@ * @purpose Pull remote changes for current branch. * @post Workspace status is refreshed. */ + function tryParseJsonObject(value) { + const source = String(value || '').trim(); + if (!source.startsWith('{') || !source.endsWith('}')) return null; + try { + const parsed = JSON.parse(source); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch (_e) { + return null; + } + } + + function extractUnfinishedMergeContext(error) { + if (!error || Number(error?.status) !== 409) return null; + const parsedMessage = tryParseJsonObject(error?.message); + const detail = error?.detail && typeof error.detail === 'object' ? error.detail : null; + const payload = detail || parsedMessage; + if (!payload || payload.error_code !== 'GIT_UNFINISHED_MERGE') return null; + + const commands = Array.isArray(payload.manual_commands) + ? payload.manual_commands.filter(Boolean).map((item) => String(item)) + : []; + + const nextSteps = Array.isArray(payload.next_steps) + ? payload.next_steps.filter(Boolean).map((item) => String(item)) + : []; + + return { + message: String(payload.message || ''), + repositoryPath: String(payload.repository_path || ''), + gitDir: String(payload.git_dir || ''), + currentBranch: String(payload.current_branch || ''), + mergeHead: String(payload.merge_head || ''), + mergeMessagePreview: String(payload.merge_message_preview || ''), + nextSteps, + commands, + }; + } + + function openUnfinishedMergeDialogFromError(error) { + const context = extractUnfinishedMergeContext(error); + if (!context) return false; + unfinishedMergeContext = context; + showUnfinishedMergeDialog = true; + return true; + } + + async function loadMergeRecoveryState() { + mergeRecoveryLoading = true; + try { + const status = await gitService.getMergeStatus(dashboardId, envId); + if (!status?.has_unfinished_merge) { + closeUnfinishedMergeDialog(); + return; + } + unfinishedMergeContext = { + ...(unfinishedMergeContext || {}), + message: unfinishedMergeContext?.message || ($t.git?.unfinished_merge?.default_message || ''), + repositoryPath: String(status.repository_path || unfinishedMergeContext?.repositoryPath || ''), + gitDir: String(status.git_dir || unfinishedMergeContext?.gitDir || ''), + currentBranch: String(status.current_branch || unfinishedMergeContext?.currentBranch || ''), + mergeHead: String(status.merge_head || unfinishedMergeContext?.mergeHead || ''), + mergeMessagePreview: String(status.merge_message_preview || unfinishedMergeContext?.mergeMessagePreview || ''), + nextSteps: Array.isArray(unfinishedMergeContext?.nextSteps) ? unfinishedMergeContext.nextSteps : [], + commands: Array.isArray(unfinishedMergeContext?.commands) ? unfinishedMergeContext.commands : [], + conflictsCount: Number(status.conflicts_count || 0), + }; + } catch (e) { + toast(e.message || ($t.git?.unfinished_merge?.load_status_failed || 'Failed to load merge status'), 'error'); + } finally { + mergeRecoveryLoading = false; + } + } + + function closeUnfinishedMergeDialog() { + showUnfinishedMergeDialog = false; + unfinishedMergeContext = null; + mergeConflicts = []; + showConflictResolver = false; + } + + async function handleOpenConflictResolver() { + mergeRecoveryLoading = true; + try { + mergeConflicts = await gitService.getMergeConflicts(dashboardId, envId); + if (!Array.isArray(mergeConflicts) || mergeConflicts.length === 0) { + toast($t.git?.unfinished_merge?.no_conflicts || 'No unresolved conflicts were found', 'info'); + return; + } + showConflictResolver = true; + } catch (e) { + toast(e.message || ($t.git?.unfinished_merge?.load_conflicts_failed || 'Failed to load merge conflicts'), 'error'); + } finally { + mergeRecoveryLoading = false; + } + } + + async function handleResolveConflicts(event) { + const detail = event?.detail || {}; + const resolutions = Object.entries(detail).map(([file_path, resolution]) => ({ + file_path, + resolution, + })); + if (!resolutions.length) { + toast($t.git?.unfinished_merge?.resolve_empty || 'No conflict resolutions selected', 'warning'); + return; + } + + mergeResolveInProgress = true; + try { + await gitService.resolveMergeConflicts(dashboardId, resolutions, envId); + toast($t.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success'); + showConflictResolver = false; + await loadMergeRecoveryState(); + await loadWorkspace(); + } catch (e) { + toast(e.message || ($t.git?.unfinished_merge?.resolve_failed || 'Failed to resolve conflicts'), 'error'); + } finally { + mergeResolveInProgress = false; + } + } + + async function handleAbortUnfinishedMerge() { + mergeAbortInProgress = true; + try { + await gitService.abortMerge(dashboardId, envId); + toast($t.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success'); + closeUnfinishedMergeDialog(); + await loadWorkspace(); + } catch (e) { + toast(e.message || ($t.git?.unfinished_merge?.abort_failed || 'Failed to abort merge'), 'error'); + } finally { + mergeAbortInProgress = false; + } + } + + async function handleContinueUnfinishedMerge() { + mergeContinueInProgress = true; + try { + await gitService.continueMerge(dashboardId, '', envId); + toast($t.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success'); + closeUnfinishedMergeDialog(); + await loadWorkspace(); + } catch (e) { + toast(e.message || ($t.git?.unfinished_merge?.continue_failed || 'Failed to continue merge'), 'error'); + await loadMergeRecoveryState(); + } finally { + mergeContinueInProgress = false; + } + } + + function getUnfinishedMergeCommandsText() { + if (!unfinishedMergeContext?.commands?.length) return ''; + return unfinishedMergeContext.commands.join('\n'); + } + + async function handleCopyUnfinishedMergeCommands() { + const commandsText = getUnfinishedMergeCommandsText(); + if (!commandsText) { + toast($t.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning'); + return; + } + copyingUnfinishedMergeCommands = true; + try { + if (typeof navigator === 'undefined' || !navigator?.clipboard?.writeText) { + throw new Error('Clipboard API unavailable'); + } + await navigator.clipboard.writeText(commandsText); + toast($t.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success'); + } catch (_e) { + toast($t.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error'); + } finally { + copyingUnfinishedMergeCommands = false; + } + } + async function handlePull() { isPulling = true; try { @@ -309,7 +494,12 @@ toast($t.git?.pull_success || 'Изменения получены из Git', 'success'); await loadWorkspace(); } catch (e) { - toast(e.message, 'error'); + const handledByDialog = openUnfinishedMergeDialogFromError(e); + if (handledByDialog) { + await loadMergeRecoveryState(); + } else { + toast(e.message, 'error'); + } } finally { isPulling = false; } @@ -891,9 +1081,118 @@ {/if} + + {#if showUnfinishedMergeDialog && unfinishedMergeContext} + + {/if} {/if} + + frontend/src/components/git/GitManager.svelte + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const COMPONENT_PATH = path.resolve( + process.cwd(), + 'src/components/git/GitManager.svelte', +); + +describe('GitManager unfinished merge dialog contract', () => { + it('keeps 409 unfinished-merge detection and WebUI dialog recovery flow in pull handler', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('Number(error?.status) !== 409'); + expect(source).toContain("payload.error_code !== 'GIT_UNFINISHED_MERGE'"); + expect(source).toContain('function openUnfinishedMergeDialogFromError(error)'); + expect(source).toContain('showUnfinishedMergeDialog = true;'); + expect(source).toContain('const handledByDialog = openUnfinishedMergeDialogFromError(e);'); + expect(source).toContain('await loadMergeRecoveryState();'); + }); + + it('renders unresolved-merge dialog details and web recovery actions', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('{#if showUnfinishedMergeDialog && unfinishedMergeContext}'); + expect(source).toContain('unfinishedMergeContext.repositoryPath'); + expect(source).toContain('unfinishedMergeContext.currentBranch'); + expect(source).toContain('unfinishedMergeContext.commands.join'); + expect(source).toContain('handleCopyUnfinishedMergeCommands'); + expect(source).toContain('handleOpenConflictResolver'); + expect(source).toContain('handleAbortUnfinishedMerge'); + expect(source).toContain('handleContinueUnfinishedMerge'); + expect(source).toContain('$t.git?.unfinished_merge?.copy_commands'); + }); +}); +// [/DEF:frontend.src.components.git.__tests__.git_manager_unfinished_merge_integration:Module] \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 4c082e2f..9f7b61cd 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -15,11 +15,20 @@ const API_BASE_URL = '/api'; // @POST: Returned error contains message and status fields. async function buildApiError(response) { const errorData = await response.json().catch(() => ({})); - const message = errorData.detail - ? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) + const detail = errorData?.detail; + const message = detail + ? ( + typeof detail === 'string' + ? detail + : (typeof detail?.message === 'string' ? detail.message : JSON.stringify(detail)) + ) : `API request failed with status ${response.status}`; const error = new Error(message); - error.status = response.status; + /** @type {any} */ (error).status = response.status; + /** @type {any} */ (error).detail = detail; + if (detail && typeof detail === 'object' && detail.error_code) { + /** @type {any} */ (error).error_code = String(detail.error_code); + } return error; } // [/DEF:buildApiError:Function] @@ -54,7 +63,18 @@ function shouldSuppressApiErrorToast(endpoint, error) { (error?.status === 400 || error?.status === 404) && /Repository for dashboard .* not found/i.test(String(error?.message || '')); - return isGitStatusEndpoint && isNoRepoError; + const isGitPullEndpoint = + typeof endpoint === 'string' && + endpoint.startsWith('/git/repositories/') && + endpoint.endsWith('/pull'); + const isUnfinishedMergeError = + error?.status === 409 && + ( + String(error?.error_code || '') === 'GIT_UNFINISHED_MERGE' || + String(error?.detail?.error_code || '') === 'GIT_UNFINISHED_MERGE' + ); + + return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError); } // [/DEF:shouldSuppressApiErrorToast:Function] @@ -130,7 +150,7 @@ async function fetchApiBlob(endpoint, options = {}) { if (response.status === 202) { const payload = await response.json().catch(() => ({ message: "Resource is being prepared" })); const error = new Error(payload?.message || "Resource is being prepared"); - error.status = 202; + /** @type {any} */ (error).status = 202; throw error; } if (!response.ok) { diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index e8083f75..947044cb 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -295,6 +295,18 @@ "ahead_remote": "Ahead Remote", "diverged": "Diverged", "error": "Error" + }, + "unfinished_merge": { + "title": "Repository has an unfinished merge", + "default_message": "An unfinished merge was detected. Resolve conflicts or abort merge manually, then retry Pull.", + "repository_path": "Repository path", + "branch": "Current branch", + "next_steps": "Recommended steps", + "manual_commands": "Manual recovery commands", + "copy_commands": "Copy commands", + "copy_success": "Commands copied to clipboard", + "copy_failed": "Failed to copy commands", + "copy_empty": "No commands to copy" } }, "dashboard": { diff --git a/frontend/src/lib/i18n/locales/ru.json b/frontend/src/lib/i18n/locales/ru.json index 19c1cb91..6a0ad1da 100644 --- a/frontend/src/lib/i18n/locales/ru.json +++ b/frontend/src/lib/i18n/locales/ru.json @@ -294,6 +294,18 @@ "ahead_remote": "Опережает remote", "diverged": "Расхождение", "error": "Ошибка" + }, + "unfinished_merge": { + "title": "Незавершённое слияние в репозитории", + "default_message": "В репозитории найдено незавершённое слияние. Завершите или отмените его вручную и повторите Pull.", + "repository_path": "Путь к репозиторию", + "branch": "Текущая ветка", + "next_steps": "Рекомендуемые шаги", + "manual_commands": "Команды для ручного восстановления", + "copy_commands": "Скопировать команды", + "copy_success": "Команды скопированы в буфер обмена", + "copy_failed": "Не удалось скопировать команды", + "copy_empty": "Команды для копирования отсутствуют" } }, "dashboard": { diff --git a/frontend/src/services/gitService.js b/frontend/src/services/gitService.js index d60eb233..cd42d7fb 100644 --- a/frontend/src/services/gitService.js +++ b/frontend/src/services/gitService.js @@ -147,7 +147,7 @@ export const gitService = { * @purpose Initializes or clones a Git repository for a dashboard. * @pre Dashboard must exist and config_id must be valid. * @post Repository is initialized on the backend. - * @param {number} dashboardId - ID of the dashboard. + * @param {string|number} dashboardRef - Dashboard slug or id. * @param {string} configId - ID of the Git config. * @param {string} remoteUrl - URL of the remote repository. * @returns {Promise} Initialization result. @@ -178,7 +178,7 @@ export const gitService = { * @purpose Retrieves the list of branches for a dashboard's repository. * @pre Repository must be initialized. * @post Returns a list of branches. - * @param {number} dashboardId - ID of the dashboard. + * @param {string|number} dashboardRef - Dashboard slug or id. * @returns {Promise} List of branches. */ async getBranches(dashboardRef, envId = null) { @@ -272,6 +272,77 @@ export const gitService = { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST'); }, + /** + * [DEF:getMergeStatus:Function] + * @purpose Retrieves unfinished-merge status for repository. + * @pre Repository must exist. + * @post Returns merge status payload. + * @param {string|number} dashboardRef - Dashboard slug or id. + * @returns {Promise} Merge status details. + */ + async getMergeStatus(dashboardRef, envId = null) { + console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId)); + }, + + /** + * [DEF:getMergeConflicts:Function] + * @purpose Retrieves merge conflicts list for repository. + * @pre Unfinished merge should be in progress. + * @post Returns conflict files with mine/theirs previews. + * @param {string|number} dashboardRef - Dashboard slug or id. + * @returns {Promise} List of conflict files. + */ + async getMergeConflicts(dashboardRef, envId = null) { + console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId)); + }, + + /** + * [DEF:resolveMergeConflicts:Function] + * @purpose Applies conflict resolution strategies and stages resolved files. + * @pre resolutions contains file_path/resolution entries. + * @post Conflicts are resolved and staged. + * @param {string|number} dashboardRef - Dashboard slug or id. + * @param {Array} resolutions - Resolution entries. + * @returns {Promise} Resolve result. + */ + async resolveMergeConflicts(dashboardRef, resolutions, envId = null) { + console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', { + resolutions: Array.isArray(resolutions) ? resolutions : [] + }); + }, + + /** + * [DEF:abortMerge:Function] + * @purpose Aborts current unfinished merge. + * @pre Repository exists. + * @post Merge state is aborted or reported as absent. + * @param {string|number} dashboardRef - Dashboard slug or id. + * @returns {Promise} Abort operation result. + */ + async abortMerge(dashboardRef, envId = null) { + console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST'); + }, + + /** + * [DEF:continueMerge:Function] + * @purpose Finalizes unfinished merge by creating merge commit. + * @pre All conflicts are resolved. + * @post Merge commit is created. + * @param {string|number} dashboardRef - Dashboard slug or id. + * @param {string} message - Optional commit message. + * @returns {Promise} Continue result. + */ + async continueMerge(dashboardRef, message = '', envId = null) { + console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', { + message: String(message || '').trim() || null + }); + }, + /** * [DEF:getEnvironments:Function] * @purpose Retrieves available deployment environments. diff --git a/specs/011-git-integration-dashboard/tasks.md b/specs/011-git-integration-dashboard/tasks.md index e9caa3dc..d0f95c53 100644 --- a/specs/011-git-integration-dashboard/tasks.md +++ b/specs/011-git-integration-dashboard/tasks.md @@ -2,7 +2,7 @@ **Feature**: Git Integration for Dashboard Development **Status**: Completed -**Total Tasks**: 35 +**Total Tasks**: 43 ## Phase 1: Setup **Goal**: Initialize project structure and dependencies. @@ -92,4 +92,17 @@ - [x] D001 Diagnose source-of-truth mismatch for server URL in push flow - [x] D002 Validate diagnosis with backend push diagnostics logs -- [x] D003 Apply behavioral fix only after diagnosis confirmation \ No newline at end of file +- [x] D003 Apply behavioral fix only after diagnosis confirmation + +## Phase 10: Operational Debug (2026-03-08) +**Goal**: Diagnose unexpected 409 on pull caused by unfinished merge detection. + +- [x] D004 Add pull diagnostics logs for merge marker, repo path, branch, and binding metadata +- [x] D005 Confirm root cause hypothesis from new diagnostics before applying any behavior fix +- [x] D006 Implement richer 409 API response for unfinished merge (repo path, branch, explicit manual steps) + +## Phase 11: Operational Debug (2026-03-08) +**Goal**: Add GitManager remediation dialog for unfinished merge 409 with actionable guidance. + +- [x] D007 Implement unresolved-merge resolution dialog in `frontend/src/components/git/GitManager.svelte` +- [x] D008 Add frontend contract test for unfinished-merge dialog behavior in `frontend/src/components/git/__tests__/git_manager.unfinished_merge.integration.test.js` \ No newline at end of file