From c7e9b5b6c5c798a5740247519573e95712a04245 Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 8 Mar 2026 11:28:00 +0300 Subject: [PATCH] feat: automatically align Git repository origin host with configured server URL to prevent mismatches --- backend/src/services/git_service.py | 207 +++++++++++++++++- .../src/components/git/BranchSelector.svelte | 2 +- .../src/components/git/DeploymentModal.svelte | 2 +- frontend/src/components/git/GitManager.svelte | 43 ++++ frontend/src/lib/ui/PageHeader.svelte | 4 +- specs/011-git-integration-dashboard/tasks.md | 9 +- 6 files changed, 261 insertions(+), 6 deletions(-) diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 1157a296..bf0e8b43 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -27,7 +27,7 @@ from pathlib import Path from urllib.parse import quote, urlparse from src.core.logger import logger, belief_scope from src.models.git import GitProvider -from src.models.git import GitRepository +from src.models.git import GitRepository, GitServerConfig from src.models.config import AppConfigRecord from src.core.database import SessionLocal @@ -569,6 +569,154 @@ class GitService: logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}") # [/DEF:commit_changes:Function] + # [DEF:_extract_http_host:Function] + # @PURPOSE: Extract normalized host[:port] from HTTP(S) URL. + # @PRE: url_value may be empty. + # @POST: Returns lowercase host token or None. + # @RETURN: Optional[str] + def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]: + normalized = str(url_value or "").strip() + if not normalized: + return None + try: + parsed = urlparse(normalized) + except Exception: + return None + if parsed.scheme not in {"http", "https"}: + return None + host = parsed.hostname + if not host: + return None + if parsed.port: + return f"{host.lower()}:{parsed.port}" + return host.lower() + # [/DEF:_extract_http_host:Function] + + # [DEF:_strip_url_credentials:Function] + # @PURPOSE: Remove credentials from URL while preserving scheme/host/path. + # @PRE: url_value may contain credentials. + # @POST: Returns URL without username/password. + # @RETURN: str + def _strip_url_credentials(self, url_value: str) -> str: + normalized = str(url_value or "").strip() + if not normalized: + return normalized + try: + parsed = urlparse(normalized) + except Exception: + return normalized + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return normalized + host = parsed.hostname + if parsed.port: + host = f"{host}:{parsed.port}" + return parsed._replace(netloc=host).geturl() + # [/DEF:_strip_url_credentials:Function] + + # [DEF:_replace_host_in_url:Function] + # @PURPOSE: Replace source URL host with host from configured server URL. + # @PRE: source_url and config_url are HTTP(S) URLs. + # @POST: Returns source URL with updated host (credentials preserved) or None. + # @RETURN: Optional[str] + def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]: + source = str(source_url or "").strip() + config = str(config_url or "").strip() + if not source or not config: + return None + try: + source_parsed = urlparse(source) + config_parsed = urlparse(config) + except Exception: + return None + + if source_parsed.scheme not in {"http", "https"}: + return None + if config_parsed.scheme not in {"http", "https"}: + return None + if not source_parsed.hostname or not config_parsed.hostname: + return None + + target_host = config_parsed.hostname + if config_parsed.port: + target_host = f"{target_host}:{config_parsed.port}" + + auth_part = "" + if source_parsed.username: + auth_part = quote(source_parsed.username, safe="") + if source_parsed.password is not None: + auth_part = f"{auth_part}:{quote(source_parsed.password, safe='')}" + auth_part = f"{auth_part}@" + + new_netloc = f"{auth_part}{target_host}" + return source_parsed._replace(netloc=new_netloc).geturl() + # [/DEF:_replace_host_in_url:Function] + + # [DEF:_align_origin_host_with_config:Function] + # @PURPOSE: Auto-align local origin host to configured Git server host when they drift. + # @PRE: origin remote exists. + # @POST: origin URL host updated and DB binding normalized when mismatch detected. + # @RETURN: Optional[str] + def _align_origin_host_with_config( + self, + dashboard_id: int, + origin, + config_url: Optional[str], + current_origin_url: Optional[str], + binding_remote_url: Optional[str], + ) -> Optional[str]: + config_host = self._extract_http_host(config_url) + source_origin_url = str(current_origin_url or "").strip() or str(binding_remote_url or "").strip() + origin_host = self._extract_http_host(source_origin_url) + + if not config_host or not origin_host: + return None + if config_host == origin_host: + return None + + aligned_url = self._replace_host_in_url(source_origin_url, config_url) + if not aligned_url: + return None + + logger.warning( + "[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url", + dashboard_id, + config_host, + origin_host, + ) + + try: + origin.set_url(aligned_url) + except Exception as e: + logger.warning( + "[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s", + dashboard_id, + e, + ) + return None + + try: + session = SessionLocal() + try: + db_repo = ( + session.query(GitRepository) + .filter(GitRepository.dashboard_id == int(dashboard_id)) + .first() + ) + if db_repo: + db_repo.remote_url = self._strip_url_credentials(aligned_url) + session.commit() + finally: + session.close() + except Exception as e: + logger.warning( + "[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s", + dashboard_id, + e, + ) + + return aligned_url + # [/DEF:_align_origin_host_with_config:Function] + # [DEF:push_changes:Function] # @PURPOSE: Push local commits to remote. # @PRE: Repository exists and has an 'origin' remote. @@ -588,6 +736,63 @@ class GitService: logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + # Emit diagnostic context to verify config-url vs repository-origin mismatch. + try: + origin_urls = list(origin.urls) + except Exception: + origin_urls = [] + binding_remote_url = None + binding_config_id = None + binding_config_url = None + try: + session = SessionLocal() + try: + db_repo = ( + session.query(GitRepository) + .filter(GitRepository.dashboard_id == int(dashboard_id)) + .first() + ) + if db_repo: + binding_remote_url = db_repo.remote_url + binding_config_id = db_repo.config_id + db_config = ( + session.query(GitServerConfig) + .filter(GitServerConfig.id == db_repo.config_id) + .first() + ) + if db_config: + binding_config_url = db_config.url + finally: + session.close() + except Exception as diag_error: + logger.warning( + "[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s", + dashboard_id, + diag_error, + ) + + realigned_origin_url = self._align_origin_host_with_config( + dashboard_id=dashboard_id, + origin=origin, + config_url=binding_config_url, + current_origin_url=(origin_urls[0] if origin_urls else None), + binding_remote_url=binding_remote_url, + ) + try: + origin_urls = list(origin.urls) + except Exception: + origin_urls = [] + + logger.info( + "[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s", + dashboard_id, + binding_config_id, + binding_config_url, + binding_remote_url, + origin_urls, + bool(realigned_origin_url), + ) + # Check if current branch has an upstream try: current_branch = repo.active_branch diff --git a/frontend/src/components/git/BranchSelector.svelte b/frontend/src/components/git/BranchSelector.svelte index 07bda48a..78941a77 100644 --- a/frontend/src/components/git/BranchSelector.svelte +++ b/frontend/src/components/git/BranchSelector.svelte @@ -22,7 +22,7 @@ let { dashboardId, envId = null, - currentBranch = 'main', + currentBranch = $bindable('main'), } = $props(); // [/SECTION] diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte index 8f7888ab..e157dc6d 100644 --- a/frontend/src/components/git/DeploymentModal.svelte +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -19,7 +19,7 @@ // [/SECTION] // [SECTION: PROPS] - let { dashboardId, envId = null, show = false, preferredTargetStage = "" } = $props(); + let { dashboardId, envId = null, show = $bindable(false), preferredTargetStage = "" } = $props(); // [/SECTION] diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte index 72d6dc7f..e0c5a78d 100644 --- a/frontend/src/components/git/GitManager.svelte +++ b/frontend/src/components/git/GitManager.svelte @@ -61,6 +61,8 @@ let isPushing = $state(false); let autoPushAfterCommit = $state(true); let repositoryProvider = $state(''); + let repositoryBindingRemoteUrl = $state(''); + let repositoryConfigUrl = $state(''); // [/SECTION] const hasWorkspaceChanges = $derived.by(() => { @@ -441,6 +443,24 @@ } // [/DEF:resolvePushProviderLabel:Function] + // [DEF:extractHttpHost:Function] + /** + * @purpose Extract comparable host[:port] from URL string. + * @post Returns lowercase host token or empty string. + */ + function extractHttpHost(urlValue) { + const normalized = String(urlValue || '').trim(); + if (!normalized) return ''; + try { + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + return String(parsed.host || '').toLowerCase(); + } catch (_e) { + return ''; + } + } + // [/DEF:extractHttpHost:Function] + // [DEF:buildSuggestedRepoName:Function] /** * @purpose Build deterministic repository name from dashboard title/id. @@ -544,6 +564,18 @@ } // [/DEF:handleBackdropClick:Function] + const originHost = $derived.by(() => extractHttpHost(repositoryBindingRemoteUrl)); + const configHost = $derived.by(() => extractHttpHost(repositoryConfigUrl)); + const hasOriginConfigMismatch = $derived.by(() => { + if (!originHost || !configHost) return false; + return originHost !== configHost; + }); + + $effect(() => { + const selectedConfig = getSelectedConfig() || resolveDefaultConfig(configs); + repositoryConfigUrl = selectedConfig?.url || ''; + }); + onMount(async () => { try { configs = await gitService.getConfigs(); @@ -559,9 +591,11 @@ try { const binding = await gitService.getRepositoryBinding(dashboardId, envId); repositoryProvider = binding?.provider || ''; + repositoryBindingRemoteUrl = binding?.remote_url || ''; if (binding?.config_id) selectedConfigId = String(binding.config_id); } catch (_e) { repositoryProvider = ''; + repositoryBindingRemoteUrl = ''; } } }); @@ -641,6 +675,15 @@ {:else}
+ {#if hasOriginConfigMismatch} +
+
Git server mismatch detected
+
+ Configured server host: {configHost}, repository origin host: {originHost}. + Next push will auto-realign origin host to configured server. +
+
+ {/if}
diff --git a/frontend/src/lib/ui/PageHeader.svelte b/frontend/src/lib/ui/PageHeader.svelte index 5dda91a3..2daf8bf6 100644 --- a/frontend/src/lib/ui/PageHeader.svelte +++ b/frontend/src/lib/ui/PageHeader.svelte @@ -15,8 +15,8 @@ let { title = "", class: className = "", - subtitle, - actions, + subtitle = null, + actions = null, ...rest } = $props(); // [/SECTION: PROPS] diff --git a/specs/011-git-integration-dashboard/tasks.md b/specs/011-git-integration-dashboard/tasks.md index bd732e71..e9caa3dc 100644 --- a/specs/011-git-integration-dashboard/tasks.md +++ b/specs/011-git-integration-dashboard/tasks.md @@ -85,4 +85,11 @@ ## Implementation Strategy - MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured. -- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable. \ No newline at end of file +- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable. + +## Phase 9: Operational Debug (2026-03-08) +**Goal**: Validate mismatch between configured Git server URL and repository origin URL during 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] D003 Apply behavioral fix only after diagnosis confirmation \ No newline at end of file