"""Git URL parsing utilities to extract base repository URLs from browser URLs.""" from urllib.parse import urlparse def extract_base_repo_url(url: str) -> str | None: """Extract base repository URL from a browser/git URL. Examples: https://github.com/user/repo/tree/main → https://github.com/user/repo.git https://github.com/user/repo.git → https://github.com/user/repo.git git@github.com:user/repo.git → git@github.com:user/repo.git https://gitlab.com/user/repo/-/blob/main/README.md → https://gitlab.com/user/repo.git Returns None if URL doesn't match known patterns. """ # Handle SSH URLs (pass through unchanged) if url.startswith("git@"): return url if url.endswith(".git") else f"{url}.git" try: parsed = urlparse(url) except Exception: return None # Remove query parameters url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" # Extract host host = parsed.netloc.lower() # Split path path_parts = [p for p in parsed.path.split("/") if p] if not path_parts: return None # Determine host type and extract base if "github.com" in host: return _extract_github_url(url, path_parts) elif "gitlab.com" in host: return _extract_gitlab_url(url, path_parts) elif "bitbucket.org" in host: return _extract_bitbucket_url(url, path_parts) else: # Generic host - try basic extraction return _extract_generic_url(url, path_parts) def _extract_github_url(url: str, path_parts: list[str]) -> str | None: """Extract base repo URL from GitHub URL.""" # Need at least owner/repo if len(path_parts) < 2: return None # Find the repo name (second path part) # Remove trailing .git if present repo_name = path_parts[1] if repo_name.endswith(".git"): repo_name = repo_name[:-4] # Reconstruct base URL base = f"https://github.com/{path_parts[0]}/{repo_name}" # Add .git suffix return f"{base}.git" def _extract_gitlab_url(url: str, path_parts: list[str]) -> str | None: """Extract base repo URL from GitLab URL.""" # Need at least owner/repo if len(path_parts) < 2: return None # Find the repo name (second path part) repo_name = path_parts[1] if repo_name.endswith(".git"): repo_name = repo_name[:-4] # Reconstruct base URL base = f"https://gitlab.com/{path_parts[0]}/{repo_name}" return f"{base}.git" def _extract_bitbucket_url(url: str, path_parts: list[str]) -> str | None: """Extract base repo URL from Bitbucket URL.""" # Need at least owner/repo if len(path_parts) < 2: return None # Find the repo name (second path part) repo_name = path_parts[1] if repo_name.endswith(".git"): repo_name = repo_name[:-4] # Reconstruct base URL base = f"https://bitbucket.org/{path_parts[0]}/{repo_name}" return f"{base}.git" def _extract_generic_url(url: str, path_parts: list[str]) -> str | None: """Extract base repo URL from generic git host URL.""" # Need at least owner/repo if len(path_parts) < 2: return None # Find the repo name (second path part) repo_name = path_parts[1] if repo_name.endswith(".git"): repo_name = repo_name[:-4] # Reconstruct base URL parsed = urlparse(url) base = f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}/{repo_name}" return f"{base}.git" def is_valid_clone_url(url: str) -> bool: """Check if URL is already a valid git clone URL. A valid clone URL: - Is an SSH URL (git@host:path) - Ends with .git - Has no browser-specific path segments """ # SSH URLs are always valid if url.startswith("git@"): return True try: parsed = urlparse(url) except Exception: return False path = parsed.path # Must end with .git for HTTPS if not path.endswith(".git"): return False # Check for browser-specific paths browser_paths = ["/tree/", "/blob/", "/pull/", "/issues/", "/actions/", "-/tree/", "-/blob/", "-/merge_requests/", "/src/"] for bp in browser_paths: if bp in path: return False return True def parse_git_url(url: str) -> dict: """Parse a git URL and return detailed information. Returns: { "original_url": str, "base_url": str | None, "is_valid_clone_url": bool, "needs_parsing": bool, "host": str | None, "message": str, "error_code": str | None, } """ result = { "original_url": url, "base_url": None, "is_valid_clone_url": False, "needs_parsing": False, "host": None, "message": "", "error_code": None, } # Check if empty if not url or not url.strip(): result["message"] = "Please enter a URL" result["error_code"] = "INVALID_URL" return result url = url.strip() # Try to parse try: parsed = urlparse(url) except Exception: result["message"] = "Please enter a valid URL" result["error_code"] = "INVALID_URL" return result # Extract host if parsed.netloc: result["host"] = parsed.netloc.lower() elif url.startswith("git@"): # SSH URL: git@host:path parts = url.split(":", 1) if len(parts) == 2: result["host"] = parts[0].replace("git@", "") else: result["message"] = "Please enter a valid URL" result["error_code"] = "INVALID_URL" return result # Check if already valid if is_valid_clone_url(url): result["base_url"] = url result["is_valid_clone_url"] = True result["needs_parsing"] = False result["message"] = "Valid git repository URL" return result # Try to extract base URL base = extract_base_repo_url(url) if base: result["base_url"] = base result["needs_parsing"] = True result["message"] = f"This looks like a browser URL. Did you mean: {base}?" result["error_code"] = "URL_NEEDS_PARSING" else: result["message"] = "Could not parse this URL. Please enter a valid git repository URL." result["error_code"] = "INVALID_URL" return result