rebase rework

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

View File

@@ -313,4 +313,128 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
assert identity_service.pulled_dashboard_id == 12 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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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