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
|
assert identity_service.pulled_dashboard_id == 12
|
||||||
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
|
# [/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]
|
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from src.api.routes.git_schemas import (
|
|||||||
GiteaRepoCreateRequest, GiteaRepoSchema,
|
GiteaRepoCreateRequest, GiteaRepoSchema,
|
||||||
RemoteRepoCreateRequest, RemoteRepoSchema,
|
RemoteRepoCreateRequest, RemoteRepoSchema,
|
||||||
PromoteRequest, PromoteResponse,
|
PromoteRequest, PromoteResponse,
|
||||||
|
MergeStatusSchema, MergeConflictFileSchema, MergeResolveRequest, MergeContinueRequest,
|
||||||
)
|
)
|
||||||
from src.services.git_service import GitService
|
from src.services.git_service import GitService
|
||||||
from src.core.superset_client import SupersetClient
|
from src.core.superset_client import SupersetClient
|
||||||
@@ -825,6 +826,27 @@ async def pull_changes(
|
|||||||
with belief_scope("pull_changes"):
|
with belief_scope("pull_changes"):
|
||||||
try:
|
try:
|
||||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
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)
|
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||||
git_service.pull_changes(dashboard_id)
|
git_service.pull_changes(dashboard_id)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
@@ -834,6 +856,122 @@ async def pull_changes(
|
|||||||
_handle_unexpected_git_route_error("pull_changes", e)
|
_handle_unexpected_git_route_error("pull_changes", e)
|
||||||
# [/DEF:pull_changes:Function]
|
# [/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]
|
# [DEF:sync_dashboard:Function]
|
||||||
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
|
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
|
||||||
# @PRE: `dashboard_ref` is valid; GitPlugin is available.
|
# @PRE: `dashboard_ref` is valid; GitPlugin is available.
|
||||||
|
|||||||
@@ -126,6 +126,42 @@ class ConflictResolution(BaseModel):
|
|||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
# [/DEF:ConflictResolution:Class]
|
# [/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]
|
# [DEF:DeploymentEnvironmentSchema:Class]
|
||||||
# @PURPOSE: Schema for representing a target deployment environment.
|
# @PURPOSE: Schema for representing a target deployment environment.
|
||||||
class DeploymentEnvironmentSchema(BaseModel):
|
class DeploymentEnvironmentSchema(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import shutil
|
|||||||
from git import Repo
|
from git import Repo
|
||||||
from git.exc import GitCommandError
|
from git.exc import GitCommandError
|
||||||
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
||||||
|
from git.objects.blob import Blob
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -834,6 +835,204 @@ class GitService:
|
|||||||
# @PURPOSE: Pull changes from remote.
|
# @PURPOSE: Pull changes from remote.
|
||||||
# @PRE: Repository exists and has an 'origin' remote.
|
# @PRE: Repository exists and has an 'origin' remote.
|
||||||
# @POST: Changes from origin are pulled and merged into the active branch.
|
# @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):
|
def pull_changes(self, dashboard_id: int):
|
||||||
with belief_scope("GitService.pull_changes"):
|
with belief_scope("GitService.pull_changes"):
|
||||||
repo = self.get_repo(dashboard_id)
|
repo = self.get_repo(dashboard_id)
|
||||||
@@ -841,23 +1040,46 @@ class GitService:
|
|||||||
# Check for unfinished merge (MERGE_HEAD exists)
|
# Check for unfinished merge (MERGE_HEAD exists)
|
||||||
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
||||||
if os.path.exists(merge_head_path):
|
if os.path.exists(merge_head_path):
|
||||||
logger.warning(f"[pull_changes][Action] Unfinished merge detected for dashboard {dashboard_id}")
|
payload = self._build_unfinished_merge_payload(repo)
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
logger.warning(
|
||||||
detail=(
|
"[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,
|
||||||
"1) Разрешите конфликты и выполните коммит, или "
|
payload["repository_path"],
|
||||||
"2) Выполните 'git merge --abort' для отмены слияния."
|
payload["git_dir"],
|
||||||
),
|
payload["current_branch"],
|
||||||
|
payload["merge_head"],
|
||||||
|
payload["merge_message_preview"],
|
||||||
)
|
)
|
||||||
|
raise HTTPException(status_code=409, detail=payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
origin = repo.remote(name='origin')
|
origin = repo.remote(name='origin')
|
||||||
current_branch = repo.active_branch.name
|
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)
|
origin.fetch(prune=True)
|
||||||
remote_ref = f"origin/{current_branch}"
|
remote_ref = f"origin/{current_branch}"
|
||||||
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
|
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:
|
if not has_remote_branch:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
|
|||||||
@@ -15,10 +15,9 @@
|
|||||||
// [/SECTION]
|
// [/SECTION]
|
||||||
|
|
||||||
// [SECTION: PROPS]
|
// [SECTION: PROPS]
|
||||||
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
|
|
||||||
let {
|
let {
|
||||||
conflicts = [],
|
conflicts = [],
|
||||||
show = false,
|
show = $bindable(false),
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// [/SECTION]
|
// [/SECTION]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
|
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
|
||||||
import BranchSelector from './BranchSelector.svelte';
|
import BranchSelector from './BranchSelector.svelte';
|
||||||
import DeploymentModal from './DeploymentModal.svelte';
|
import DeploymentModal from './DeploymentModal.svelte';
|
||||||
|
import ConflictResolver from './ConflictResolver.svelte';
|
||||||
// [/SECTION]
|
// [/SECTION]
|
||||||
|
|
||||||
// [SECTION: PROPS]
|
// [SECTION: PROPS]
|
||||||
@@ -63,6 +64,15 @@
|
|||||||
let repositoryProvider = $state('');
|
let repositoryProvider = $state('');
|
||||||
let repositoryBindingRemoteUrl = $state('');
|
let repositoryBindingRemoteUrl = $state('');
|
||||||
let repositoryConfigUrl = $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]
|
// [/SECTION]
|
||||||
|
|
||||||
const hasWorkspaceChanges = $derived.by(() => {
|
const hasWorkspaceChanges = $derived.by(() => {
|
||||||
@@ -302,6 +312,181 @@
|
|||||||
* @purpose Pull remote changes for current branch.
|
* @purpose Pull remote changes for current branch.
|
||||||
* @post Workspace status is refreshed.
|
* @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() {
|
async function handlePull() {
|
||||||
isPulling = true;
|
isPulling = true;
|
||||||
try {
|
try {
|
||||||
@@ -309,7 +494,12 @@
|
|||||||
toast($t.git?.pull_success || 'Изменения получены из Git', 'success');
|
toast($t.git?.pull_success || 'Изменения получены из Git', 'success');
|
||||||
await loadWorkspace();
|
await loadWorkspace();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
const handledByDialog = openUnfinishedMergeDialogFromError(e);
|
||||||
|
if (handledByDialog) {
|
||||||
|
await loadMergeRecoveryState();
|
||||||
|
} else {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isPulling = false;
|
isPulling = false;
|
||||||
}
|
}
|
||||||
@@ -891,9 +1081,118 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showUnfinishedMergeDialog && unfinishedMergeContext}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
|
||||||
|
>
|
||||||
|
<div class="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-amber-300 bg-white p-5 shadow-2xl">
|
||||||
|
<div class="mb-3 text-lg font-semibold text-amber-900">
|
||||||
|
{$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
|
||||||
|
</div>
|
||||||
|
<p class="mb-4 text-sm text-slate-700">
|
||||||
|
{unfinishedMergeContext.message || ($t.git?.unfinished_merge?.default_message || 'An unfinished merge was detected in this repository.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 p-2">
|
||||||
|
<div class="text-xs text-slate-500">{$t.git?.unfinished_merge?.repository_path || 'Repository path'}</div>
|
||||||
|
<div class="break-all font-mono text-slate-800">{unfinishedMergeContext.repositoryPath || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 p-2">
|
||||||
|
<div class="text-xs text-slate-500">{$t.git?.unfinished_merge?.branch || 'Current branch'}</div>
|
||||||
|
<div class="font-mono text-slate-800">{unfinishedMergeContext.currentBranch || '—'}</div>
|
||||||
|
</div>
|
||||||
|
{#if unfinishedMergeContext.mergeHead}
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 p-2 md:col-span-2">
|
||||||
|
<div class="text-xs text-slate-500">MERGE_HEAD</div>
|
||||||
|
<div class="break-all font-mono text-slate-800">{unfinishedMergeContext.mergeHead}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if unfinishedMergeContext.nextSteps?.length}
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1 text-sm font-semibold text-slate-700">
|
||||||
|
{$t.git?.unfinished_merge?.next_steps || 'Recommended steps'}
|
||||||
|
</div>
|
||||||
|
<ol class="list-decimal space-y-1 pl-5 text-sm text-slate-700">
|
||||||
|
{#each unfinishedMergeContext.nextSteps as step}
|
||||||
|
<li>{step}</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if unfinishedMergeContext.commands?.length}
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1 text-sm font-semibold text-slate-700">
|
||||||
|
{$t.git?.unfinished_merge?.manual_commands || 'Manual recovery commands'}
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-auto rounded border border-slate-200 bg-slate-50 p-3 text-xs text-slate-800">{unfinishedMergeContext.commands.join('\n')}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={loadMergeRecoveryState}
|
||||||
|
disabled={mergeRecoveryLoading}
|
||||||
|
isLoading={mergeRecoveryLoading}
|
||||||
|
>
|
||||||
|
{$t.common?.refresh || 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={handleCopyUnfinishedMergeCommands}
|
||||||
|
disabled={copyingUnfinishedMergeCommands}
|
||||||
|
isLoading={copyingUnfinishedMergeCommands}
|
||||||
|
>
|
||||||
|
{$t.git?.unfinished_merge?.copy_commands || 'Copy commands'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={handleOpenConflictResolver}
|
||||||
|
disabled={mergeRecoveryLoading || mergeResolveInProgress}
|
||||||
|
isLoading={mergeResolveInProgress}
|
||||||
|
>
|
||||||
|
{$t.git?.unfinished_merge?.open_resolver || 'Open conflict resolver'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={handleAbortUnfinishedMerge}
|
||||||
|
disabled={mergeAbortInProgress}
|
||||||
|
isLoading={mergeAbortInProgress}
|
||||||
|
class="border border-red-300 text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
{$t.git?.unfinished_merge?.abort_merge || 'Abort merge'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleContinueUnfinishedMerge}
|
||||||
|
disabled={mergeContinueInProgress}
|
||||||
|
isLoading={mergeContinueInProgress}
|
||||||
|
>
|
||||||
|
{$t.git?.unfinished_merge?.continue_merge || 'Continue merge'}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={closeUnfinishedMergeDialog}>
|
||||||
|
{$t.common?.close || 'Close'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConflictResolver
|
||||||
|
conflicts={mergeConflicts}
|
||||||
|
bind:show={showConflictResolver}
|
||||||
|
on:resolve={handleResolveConflicts}
|
||||||
|
/>
|
||||||
|
|
||||||
<DeploymentModal
|
<DeploymentModal
|
||||||
{dashboardId}
|
{dashboardId}
|
||||||
{envId}
|
{envId}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// [DEF:frontend.src.components.git.__tests__.git_manager_unfinished_merge_integration:Module]
|
||||||
|
// @TIER: STANDARD
|
||||||
|
// @SEMANTICS: git-manager, unfinished-merge, dialog, integration-test
|
||||||
|
// @PURPOSE: Protect unresolved-merge dialog contract in GitManager pull flow.
|
||||||
|
// @LAYER: UI Tests
|
||||||
|
// @RELATION: VERIFIES -> 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]
|
||||||
@@ -15,11 +15,20 @@ const API_BASE_URL = '/api';
|
|||||||
// @POST: Returned error contains message and status fields.
|
// @POST: Returned error contains message and status fields.
|
||||||
async function buildApiError(response) {
|
async function buildApiError(response) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const message = errorData.detail
|
const detail = errorData?.detail;
|
||||||
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(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}`;
|
: `API request failed with status ${response.status}`;
|
||||||
const error = new Error(message);
|
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;
|
return error;
|
||||||
}
|
}
|
||||||
// [/DEF:buildApiError:Function]
|
// [/DEF:buildApiError:Function]
|
||||||
@@ -54,7 +63,18 @@ function shouldSuppressApiErrorToast(endpoint, error) {
|
|||||||
(error?.status === 400 || error?.status === 404) &&
|
(error?.status === 400 || error?.status === 404) &&
|
||||||
/Repository for dashboard .* not found/i.test(String(error?.message || ''));
|
/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]
|
// [/DEF:shouldSuppressApiErrorToast:Function]
|
||||||
|
|
||||||
@@ -130,7 +150,7 @@ async function fetchApiBlob(endpoint, options = {}) {
|
|||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
const payload = await response.json().catch(() => ({ message: "Resource is being prepared" }));
|
const payload = await response.json().catch(() => ({ message: "Resource is being prepared" }));
|
||||||
const error = new Error(payload?.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;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -295,6 +295,18 @@
|
|||||||
"ahead_remote": "Ahead Remote",
|
"ahead_remote": "Ahead Remote",
|
||||||
"diverged": "Diverged",
|
"diverged": "Diverged",
|
||||||
"error": "Error"
|
"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": {
|
"dashboard": {
|
||||||
|
|||||||
@@ -294,6 +294,18 @@
|
|||||||
"ahead_remote": "Опережает remote",
|
"ahead_remote": "Опережает remote",
|
||||||
"diverged": "Расхождение",
|
"diverged": "Расхождение",
|
||||||
"error": "Ошибка"
|
"error": "Ошибка"
|
||||||
|
},
|
||||||
|
"unfinished_merge": {
|
||||||
|
"title": "Незавершённое слияние в репозитории",
|
||||||
|
"default_message": "В репозитории найдено незавершённое слияние. Завершите или отмените его вручную и повторите Pull.",
|
||||||
|
"repository_path": "Путь к репозиторию",
|
||||||
|
"branch": "Текущая ветка",
|
||||||
|
"next_steps": "Рекомендуемые шаги",
|
||||||
|
"manual_commands": "Команды для ручного восстановления",
|
||||||
|
"copy_commands": "Скопировать команды",
|
||||||
|
"copy_success": "Команды скопированы в буфер обмена",
|
||||||
|
"copy_failed": "Не удалось скопировать команды",
|
||||||
|
"copy_empty": "Команды для копирования отсутствуют"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export const gitService = {
|
|||||||
* @purpose Initializes or clones a Git repository for a dashboard.
|
* @purpose Initializes or clones a Git repository for a dashboard.
|
||||||
* @pre Dashboard must exist and config_id must be valid.
|
* @pre Dashboard must exist and config_id must be valid.
|
||||||
* @post Repository is initialized on the backend.
|
* @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} configId - ID of the Git config.
|
||||||
* @param {string} remoteUrl - URL of the remote repository.
|
* @param {string} remoteUrl - URL of the remote repository.
|
||||||
* @returns {Promise<Object>} Initialization result.
|
* @returns {Promise<Object>} Initialization result.
|
||||||
@@ -178,7 +178,7 @@ export const gitService = {
|
|||||||
* @purpose Retrieves the list of branches for a dashboard's repository.
|
* @purpose Retrieves the list of branches for a dashboard's repository.
|
||||||
* @pre Repository must be initialized.
|
* @pre Repository must be initialized.
|
||||||
* @post Returns a list of branches.
|
* @post Returns a list of branches.
|
||||||
* @param {number} dashboardId - ID of the dashboard.
|
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||||
* @returns {Promise<Array>} List of branches.
|
* @returns {Promise<Array>} List of branches.
|
||||||
*/
|
*/
|
||||||
async getBranches(dashboardRef, envId = null) {
|
async getBranches(dashboardRef, envId = null) {
|
||||||
@@ -272,6 +272,77 @@ export const gitService = {
|
|||||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
|
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<Object>} 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<Array>} 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<Object>} 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<Object>} 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<Object>} 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]
|
* [DEF:getEnvironments:Function]
|
||||||
* @purpose Retrieves available deployment environments.
|
* @purpose Retrieves available deployment environments.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Feature**: Git Integration for Dashboard Development
|
**Feature**: Git Integration for Dashboard Development
|
||||||
**Status**: Completed
|
**Status**: Completed
|
||||||
**Total Tasks**: 35
|
**Total Tasks**: 43
|
||||||
|
|
||||||
## Phase 1: Setup
|
## Phase 1: Setup
|
||||||
**Goal**: Initialize project structure and dependencies.
|
**Goal**: Initialize project structure and dependencies.
|
||||||
@@ -92,4 +92,17 @@
|
|||||||
|
|
||||||
- [x] D001 Diagnose source-of-truth mismatch for server URL in push flow
|
- [x] D001 Diagnose source-of-truth mismatch for server URL in push flow
|
||||||
- [x] D002 Validate diagnosis with backend push diagnostics logs
|
- [x] D002 Validate diagnosis with backend push diagnostics logs
|
||||||
- [x] D003 Apply behavioral fix only after diagnosis confirmation
|
- [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`
|
||||||
Reference in New Issue
Block a user