22
33import re
44import urllib .parse
5- from ..utils .github_host import is_supported_git_host , is_azure_devops_hostname , default_host , unsupported_host_error
5+ from ..utils .github_host import is_supported_git_host , is_azure_devops_hostname , is_github_hostname , default_host , unsupported_host_error
66import yaml
77from dataclasses import dataclass
88from enum import Enum
@@ -331,25 +331,25 @@ def get_install_path(self, apm_modules_dir: Path) -> Path:
331331 # ADO: org/project/repo/subdir
332332 return apm_modules_dir / repo_parts [0 ] / repo_parts [1 ] / repo_parts [2 ] / self .virtual_path
333333 elif len (repo_parts ) >= 2 :
334- # GitHub: owner/repo/subdir
335- return apm_modules_dir / repo_parts [ 0 ] / repo_parts [ 1 ] / self .virtual_path
334+ # owner/repo/subdir or group/subgroup /repo/subdir
335+ return apm_modules_dir . joinpath ( * repo_parts , self .virtual_path )
336336 else :
337337 # Virtual file/collection: use sanitized package name (flattened)
338338 package_name = self .get_virtual_package_name ()
339339 if self .is_azure_devops () and len (repo_parts ) >= 3 :
340340 # ADO: org/project/virtual-pkg-name
341341 return apm_modules_dir / repo_parts [0 ] / repo_parts [1 ] / package_name
342342 elif len (repo_parts ) >= 2 :
343- # GitHub: owner/virtual-pkg-name
343+ # owner/virtual-pkg-name (use first segment as namespace)
344344 return apm_modules_dir / repo_parts [0 ] / package_name
345345 else :
346346 # Regular package: use full repo path
347347 if self .is_azure_devops () and len (repo_parts ) >= 3 :
348348 # ADO: org/project/repo
349349 return apm_modules_dir / repo_parts [0 ] / repo_parts [1 ] / repo_parts [2 ]
350350 elif len (repo_parts ) >= 2 :
351- # GitHub: owner/repo
352- return apm_modules_dir / repo_parts [ 0 ] / repo_parts [ 1 ]
351+ # owner/repo or group/subgroup/repo (generic hosts)
352+ return apm_modules_dir . joinpath ( * repo_parts )
353353
354354 # Fallback: join all parts
355355 return apm_modules_dir .joinpath (* repo_parts )
@@ -571,15 +571,36 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
571571 # For Azure DevOps, the base package format is org/project/repo (3 segments)
572572 # Virtual packages would have 4+ segments: org/project/repo/path/to/file
573573 # For GitHub, base is owner/repo (2 segments), virtual is 3+ segments
574+ # For generic hosts (GitLab, Gitea, etc.), all segments are repo path
575+ # unless virtual indicators (file extensions, collections) are present
574576 is_ado = validated_host is not None and is_azure_devops_hostname (validated_host )
577+ is_generic_host = (validated_host is not None
578+ and not is_github_hostname (validated_host )
579+ and not is_azure_devops_hostname (validated_host ))
575580
576581 # Handle _git in ADO URLs: org/project/_git/repo -> org/project/repo
577582 if is_ado and '_git' in path_segments :
578583 git_idx = path_segments .index ('_git' )
579584 # Remove _git from the path segments
580585 path_segments = path_segments [:git_idx ] + path_segments [git_idx + 1 :]
581586
582- min_base_segments = 3 if is_ado else 2
587+ if is_ado :
588+ min_base_segments = 3
589+ elif is_generic_host :
590+ # For generic hosts (GitLab, Gitea), check for virtual indicators
591+ # If present, use 2-segment base (simple owner/repo + virtual path)
592+ # If absent, treat ALL segments as the repo path (nested groups)
593+ has_virtual_ext = any (
594+ any (seg .endswith (ext ) for ext in cls .VIRTUAL_FILE_EXTENSIONS )
595+ for seg in path_segments
596+ )
597+ has_collection = 'collections' in path_segments
598+ if has_virtual_ext or has_collection :
599+ min_base_segments = 2 # Simple repo with virtual path
600+ else :
601+ min_base_segments = len (path_segments ) # All segments = repo path
602+ else :
603+ min_base_segments = 2 # GitHub: owner/repo
583604 min_virtual_segments = min_base_segments + 1
584605
585606 if len (path_segments ) >= min_virtual_segments :
@@ -683,6 +704,8 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
683704 raise ValueError ("Invalid Azure DevOps virtual package format: must be dev.azure.com/org/project/repo/path" )
684705 repo_url = "/" .join (parts [1 :4 ]) # org/project/repo
685706 else :
707+ # For virtual packages with host prefix, base is always 2 segments
708+ # (virtual indicators already detected in early detection)
686709 repo_url = "/" .join (parts [1 :3 ]) # owner/repo
687710 elif len (parts ) >= 2 :
688711 # No host prefix
@@ -718,6 +741,9 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
718741 if is_azure_devops_hostname (host ) and len (parts ) >= 4 :
719742 # ADO format: dev.azure.com/org/project/repo
720743 user_repo = "/" .join (parts [1 :4 ])
744+ elif not is_github_hostname (host ) and not is_azure_devops_hostname (host ):
745+ # Generic host (GitLab, Gitea, etc.): all segments after host = repo path
746+ user_repo = "/" .join (parts [1 :])
721747 else :
722748 # GitHub format: github.com/user/repo
723749 user_repo = "/" .join (parts [1 :3 ])
@@ -728,6 +754,9 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
728754 # Check if default host is ADO
729755 if is_azure_devops_hostname (host ) and len (parts ) >= 3 :
730756 user_repo = "/" .join (parts [:3 ]) # org/project/repo
757+ elif host and not is_github_hostname (host ) and not is_azure_devops_hostname (host ):
758+ # Generic host: all segments = repo path
759+ user_repo = "/" .join (parts )
731760 else :
732761 user_repo = "/" .join (parts [:2 ]) # user/repo
733762 else :
@@ -739,12 +768,12 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
739768
740769 uparts = user_repo .split ("/" )
741770 is_ado_host = host and is_azure_devops_hostname (host )
742- expected_parts = 3 if is_ado_host else 2
743771
744- if len ( uparts ) < expected_parts :
745- if is_ado_host :
772+ if is_ado_host :
773+ if len ( uparts ) < 3 :
746774 raise ValueError (f"Invalid Azure DevOps repository format: { repo_url } . Expected 'org/project/repo'" )
747- else :
775+ else :
776+ if len (uparts ) < 2 :
748777 raise ValueError (f"Invalid repository format: { repo_url } . Expected 'user/repo'" )
749778
750779 # Security: validate characters to prevent injection
@@ -784,13 +813,20 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
784813
785814 # Validate path format based on host type
786815 is_ado_host = is_azure_devops_hostname (hostname )
787- expected_parts = 3 if is_ado_host else 2
788816
789- if len ( path_parts ) != expected_parts :
790- if is_ado_host :
817+ if is_ado_host :
818+ if len ( path_parts ) != 3 :
791819 raise ValueError (f"Invalid Azure DevOps repository path: expected 'org/project/repo', got '{ path } '" )
792- else :
793- raise ValueError (f"Invalid repository path: expected 'user/repo', got '{ path } '" )
820+ else :
821+ if len (path_parts ) < 2 :
822+ raise ValueError (f"Invalid repository path: expected at least 'user/repo', got '{ path } '" )
823+ # HTTPS URLs cannot embed virtual paths — reject virtual file extensions
824+ for pp in path_parts :
825+ if any (pp .endswith (ext ) for ext in cls .VIRTUAL_FILE_EXTENSIONS ):
826+ raise ValueError (
827+ f"Invalid repository path: '{ path } ' contains a virtual file extension. "
828+ f"Use the dict format with 'path:' for virtual packages in HTTPS URLs"
829+ )
794830
795831 # Validate all path parts contain only allowed characters
796832 # ADO project names may contain spaces
@@ -820,9 +856,19 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
820856 ado_project = ado_parts [1 ]
821857 ado_repo = ado_parts [2 ]
822858 else :
823- # GitHub format: user/repo (2 segments)
824- if not re .match (r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$' , repo_url ):
859+ # Non-ADO format: user/repo or group/subgroup/repo (2+ segments)
860+ segments = repo_url .split ('/' )
861+ if len (segments ) < 2 :
825862 raise ValueError (f"Invalid repository format: { repo_url } . Expected 'user/repo'" )
863+ if not all (re .match (r'^[a-zA-Z0-9._-]+$' , s ) for s in segments ):
864+ raise ValueError (f"Invalid repository format: { repo_url } . Contains invalid characters" )
865+ # SSH/HTTPS URLs cannot embed virtual paths — reject virtual file extensions
866+ for seg in segments :
867+ if any (seg .endswith (ext ) for ext in cls .VIRTUAL_FILE_EXTENSIONS ):
868+ raise ValueError (
869+ f"Invalid repository format: '{ repo_url } ' contains a virtual file extension. "
870+ f"Use the dict format with 'path:' for virtual packages in SSH/HTTPS URLs"
871+ )
826872 ado_organization = None
827873 ado_project = None
828874 ado_repo = None
0 commit comments