Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 30 additions & 36 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,57 +762,51 @@ def prune(ctx, dry_run):
for dep in declared_deps:
repo_parts = dep.repo_url.split("/")
if dep.is_virtual:
# Virtual package: include full path based on platform
package_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/virtual-pkg-name
expected_installed.add(
f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"
)
elif len(repo_parts) >= 2:
# GitHub structure: owner/virtual-pkg-name
expected_installed.add(f"{repo_parts[0]}/{package_name}")
if dep.is_virtual_subdirectory() and dep.virtual_path:
# Virtual subdirectory packages keep natural path structure.
if dep.is_azure_devops() and len(repo_parts) >= 3:
expected_installed.add(
f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}"
)
elif len(repo_parts) >= 2:
expected_installed.add(
f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}"
)
else:
# Virtual file/collection packages are flattened.
package_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
expected_installed.add(
f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"
)
elif len(repo_parts) >= 2:
expected_installed.add(f"{repo_parts[0]}/{package_name}")
else:
# Regular package: use full repo_url path
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/repo
expected_installed.add(
f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}"
)
elif len(repo_parts) >= 2:
# GitHub structure: owner/repo
expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}")
except Exception as e:
_rich_error(f"Failed to parse apm.yml: {e}")
sys.exit(1)

# Find installed packages in apm_modules/ (now org-namespaced)
# GitHub: apm_modules/owner/repo (2 levels)
# Azure DevOps: apm_modules/org/project/repo (3 levels)
# Walks the tree to find directories containing apm.yml or .apm,
# handling GitHub (2-level), ADO (3-level), and subdirectory (4+ level) packages.
installed_packages = {} # {"path": "display_name"}
if apm_modules_dir.exists():
for level1_dir in apm_modules_dir.iterdir():
if level1_dir.is_dir() and not level1_dir.name.startswith("."):
for level2_dir in level1_dir.iterdir():
if level2_dir.is_dir() and not level2_dir.name.startswith("."):
# Check if level2 has apm.yml (GitHub 2-level structure)
if (level2_dir / "apm.yml").exists() or (
level2_dir / ".apm"
).exists():
path_key = f"{level1_dir.name}/{level2_dir.name}"
installed_packages[path_key] = path_key
else:
# Check for ADO 3-level structure
for level3_dir in level2_dir.iterdir():
if (
level3_dir.is_dir()
and not level3_dir.name.startswith(".")
):
if (level3_dir / "apm.yml").exists() or (
level3_dir / ".apm"
).exists():
path_key = f"{level1_dir.name}/{level2_dir.name}/{level3_dir.name}"
installed_packages[path_key] = path_key
for candidate in apm_modules_dir.rglob("*"):
if not candidate.is_dir() or candidate.name.startswith("."):
continue
if not ((candidate / "apm.yml").exists() or (candidate / ".apm").exists()):
continue
rel_parts = candidate.relative_to(apm_modules_dir).parts
if len(rel_parts) >= 2:
path_key = "/".join(rel_parts)
installed_packages[path_key] = path_key

# Find orphaned packages (installed but not declared)
orphaned_packages = {}
Expand Down
124 changes: 56 additions & 68 deletions src/apm_cli/commands/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,94 +52,82 @@ def list_packages():
# Load project dependencies to check for orphaned packages
# GitHub: owner/repo or owner/virtual-pkg-name (2 levels)
# Azure DevOps: org/project/repo or org/project/virtual-pkg-name (3 levels)
declared_deps = set()
declared_sources = {} # dep_path → 'github' | 'azure-devops'
try:
apm_yml_path = project_root / "apm.yml"
if apm_yml_path.exists():
project_package = APMPackage.from_apm_yml(apm_yml_path)
for dep in project_package.get_apm_dependencies():
# Build the expected installed package name
repo_parts = dep.repo_url.split('/')
source = 'azure-devops' if dep.is_azure_devops() else 'github'
if dep.is_virtual:
# Virtual package: include full path based on platform
package_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/virtual-pkg-name
declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}")
elif len(repo_parts) >= 2:
# GitHub structure: owner/virtual-pkg-name
declared_deps.add(f"{repo_parts[0]}/{package_name}")
if dep.is_virtual_subdirectory() and dep.virtual_path:
# Virtual subdirectory packages keep natural path structure.
# GitHub: owner/repo/subdir
# ADO: org/project/repo/subdir
if dep.is_azure_devops() and len(repo_parts) >= 3:
declared_sources[
f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}"
] = source
elif len(repo_parts) >= 2:
declared_sources[
f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}"
] = source
else:
# Virtual file/collection packages are flattened.
package_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/virtual-pkg-name
declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"] = source
elif len(repo_parts) >= 2:
# GitHub structure: owner/virtual-pkg-name
declared_sources[f"{repo_parts[0]}/{package_name}"] = source
else:
# Regular package: use full repo_url path
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/repo
declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}")
declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}"] = source
elif len(repo_parts) >= 2:
# GitHub structure: owner/repo
declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}")
declared_sources[f"{repo_parts[0]}/{repo_parts[1]}"] = source
except Exception:
pass # Continue without orphan detection if apm.yml parsing fails

# Scan for installed packages in org-namespaced structure
# GitHub: apm_modules/owner/repo (2 levels)
# Azure DevOps: apm_modules/org/project/repo (3 levels)
# Walks the tree to find directories containing apm.yml,
# handling GitHub (2-level), ADO (3-level), and subdirectory (4+ level) packages.
installed_packages = []
orphaned_packages = []
for level1_dir in apm_modules_path.iterdir():
if level1_dir.is_dir() and not level1_dir.name.startswith('.'):
for level2_dir in level1_dir.iterdir():
if level2_dir.is_dir() and not level2_dir.name.startswith('.'):
# Check if level2 has apm.yml (GitHub 2-level structure)
apm_yml_path = level2_dir / "apm.yml"
if apm_yml_path.exists():
try:
# GitHub 2-level: org/repo format
org_repo_name = f"{level1_dir.name}/{level2_dir.name}"
package = APMPackage.from_apm_yml(apm_yml_path)
context_count, workflow_count = _count_package_files(level2_dir)

is_orphaned = org_repo_name not in declared_deps
if is_orphaned:
orphaned_packages.append(org_repo_name)

installed_packages.append({
'name': org_repo_name,
'version': package.version or 'unknown',
'source': 'orphaned' if is_orphaned else 'github',
'context': context_count,
'workflows': workflow_count,
'path': str(level2_dir),
'is_orphaned': is_orphaned
})
except Exception as e:
click.echo(f"⚠️ Warning: Failed to read package {level1_dir.name}/{level2_dir.name}: {e}")
else:
# Check for ADO 3-level structure: org/project/repo
for level3_dir in level2_dir.iterdir():
if level3_dir.is_dir() and not level3_dir.name.startswith('.'):
apm_yml_path = level3_dir / "apm.yml"
if apm_yml_path.exists():
try:
# ADO 3-level: org/project/repo format
org_repo_name = f"{level1_dir.name}/{level2_dir.name}/{level3_dir.name}"
package = APMPackage.from_apm_yml(apm_yml_path)
context_count, workflow_count = _count_package_files(level3_dir)

is_orphaned = org_repo_name not in declared_deps
if is_orphaned:
orphaned_packages.append(org_repo_name)

installed_packages.append({
'name': org_repo_name,
'version': package.version or 'unknown',
'source': 'orphaned' if is_orphaned else 'azure-devops',
'context': context_count,
'workflows': workflow_count,
'path': str(level3_dir),
'is_orphaned': is_orphaned
})
except Exception as e:
click.echo(f"⚠️ Warning: Failed to read package {level1_dir.name}/{level2_dir.name}/{level3_dir.name}: {e}")
for candidate in apm_modules_path.rglob("*"):
if not candidate.is_dir() or candidate.name.startswith('.'):
continue
apm_yml_path = candidate / "apm.yml"
if not apm_yml_path.exists():
continue
rel_parts = candidate.relative_to(apm_modules_path).parts
if len(rel_parts) < 2:
continue
org_repo_name = "/".join(rel_parts)
try:
package = APMPackage.from_apm_yml(apm_yml_path)
context_count, workflow_count = _count_package_files(candidate)

is_orphaned = org_repo_name not in declared_sources
if is_orphaned:
orphaned_packages.append(org_repo_name)

installed_packages.append({
'name': org_repo_name,
'version': package.version or 'unknown',
'source': 'orphaned' if is_orphaned else declared_sources.get(org_repo_name, 'github'),
'context': context_count,
'workflows': workflow_count,
'path': str(candidate),
'is_orphaned': is_orphaned
})
except Exception as e:
click.echo(f"⚠️ Warning: Failed to read package {org_repo_name}: {e}")

if not installed_packages:
if has_rich:
Expand Down
53 changes: 30 additions & 23 deletions src/apm_cli/primitives/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,12 @@ def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) -

# Process dependencies in declaration order
for dep_name in dependency_order:
# Handle org-namespaced structure
# Join all path parts to handle variable-length paths:
# GitHub: "owner/repo" (2 parts)
# Azure DevOps: "org/project/repo" (3 parts)
# Virtual subdirectory: "owner/repo/subdir" or deeper (3+ parts)
parts = dep_name.split("/")
if len(parts) >= 3:
# ADO structure: apm_modules/org/project/repo
dep_path = apm_modules_path / parts[0] / parts[1] / parts[2]
elif len(parts) == 2:
# GitHub structure: apm_modules/owner/repo
dep_path = apm_modules_path / parts[0] / parts[1]
else:
# Fallback for non-namespaced dependencies
dep_path = apm_modules_path / dep_name
dep_path = apm_modules_path.joinpath(*parts)

if dep_path.exists() and dep_path.is_dir():
scan_directory_with_source(dep_path, collection, source=f"dependency:{dep_name}")
Expand Down Expand Up @@ -211,26 +204,40 @@ def get_dependency_declaration_order(base_dir: str) -> List[str]:
apm_dependencies = package.get_apm_dependencies()

# Extract installed paths from dependency references
# Virtual packages use get_virtual_package_name() for the final directory component
# Virtual file/collection packages use get_virtual_package_name() (flattened),
# while virtual subdirectory packages use natural repo/subdir paths.
dependency_names = []
for dep in apm_dependencies:
if dep.alias:
dependency_names.append(dep.alias)
elif dep.is_virtual:
# Virtual packages: construct path with virtual package name
# GitHub: owner/virtual-pkg-name
# ADO: org/project/virtual-pkg-name
repo_parts = dep.repo_url.split("/")
virtual_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
# ADO structure: org/project/virtual-pkg-name
dependency_names.append(f"{repo_parts[0]}/{repo_parts[1]}/{virtual_name}")
elif len(repo_parts) >= 2:
# GitHub structure: owner/virtual-pkg-name
dependency_names.append(f"{repo_parts[0]}/{virtual_name}")

if dep.is_virtual_subdirectory() and dep.virtual_path:
# Virtual subdirectory packages keep natural path structure.
# GitHub: owner/repo/subdir
# ADO: org/project/repo/subdir
if dep.is_azure_devops() and len(repo_parts) >= 3:
dependency_names.append(
f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}"
)
elif len(repo_parts) >= 2:
dependency_names.append(
f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}"
)
else:
dependency_names.append(dep.virtual_path)
else:
# Fallback
dependency_names.append(virtual_name)
# Virtual file/collection packages are flattened by package name.
# GitHub: owner/virtual-pkg-name
# ADO: org/project/virtual-pkg-name
virtual_name = dep.get_virtual_package_name()
if dep.is_azure_devops() and len(repo_parts) >= 3:
dependency_names.append(f"{repo_parts[0]}/{repo_parts[1]}/{virtual_name}")
elif len(repo_parts) >= 2:
dependency_names.append(f"{repo_parts[0]}/{virtual_name}")
else:
dependency_names.append(virtual_name)
else:
# Regular packages: use full org/repo path
# This matches our org-namespaced directory structure
Expand Down
Loading
Loading