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
1 change: 0 additions & 1 deletion ROADMAP.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ NEXT
CLI
Add filters: `--manual`, `--discovered`, and `--missing`.
Add `pcd list --json`.
Improve `pcd roots` output with project counts and root status.
Keep `pcd` with no arguments equivalent to showing help.
Add `pcd info <project>` with project metadata and status.
Handle duplicate project names explicitly.
Expand Down
3 changes: 2 additions & 1 deletion src/pcd_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
show_config,
validate_config,
)
from pcd_cli.cli.projects import add, init, list_projects, project, refresh, remove, roots, uninit
from pcd_cli.cli.projects import add, list_projects, project, remove
from pcd_cli.cli.roots import init, refresh, roots, uninit
from pcd_cli.cli.shell import (
install_shell,
print_shell_integration,
Expand Down
2 changes: 2 additions & 0 deletions src/pcd_cli/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pcd_cli.catalog import ProjectCatalog
from pcd_cli.cli.config import config_commands
from pcd_cli.cli.projects import project_commands
from pcd_cli.cli.roots import root_commands
from pcd_cli.cli.shell import shell_commands, shell_init
from pcd_cli.config import InvalidConfigError
from pcd_cli.models import ExitCode
Expand Down Expand Up @@ -85,6 +86,7 @@ def cli(ctx: click.Context, project_name: str | None) -> None:

for command in (
*project_commands,
*root_commands,
config_commands,
shell_commands,
shell_init,
Expand Down
48 changes: 1 addition & 47 deletions src/pcd_cli/cli/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,37 +87,6 @@ def project(catalog: ProjectCatalog, query: str) -> None:
navigate_to_project(catalog, query)


@click.command()
@click.pass_obj
def init(catalog: ProjectCatalog) -> None:
"""Register the current directory as a scan root."""
current = Path.cwd()
parent = catalog.find_parent_root(current)
added = catalog.add_scan_root(current)

if not added:
click.echo(f"Already a root: {format_path(current)}")
return

if parent is not None:
click.echo(f"Note: root is inside {format_path(parent)}", err=True)

click.echo(f"Added root: {format_path(current)}")


@click.command()
@click.pass_obj
def uninit(catalog: ProjectCatalog) -> None:
"""Remove the current directory from scan roots."""
current = Path.cwd()
if catalog.remove_scan_root(current):
click.echo(f"Removed root: {format_path(current)}")
return

click.echo("Current directory is not a pcd root.", err=True)
raise click.exceptions.Exit(ExitCode.ERROR)


@click.command()
@click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
@click.option("--name", help="Custom name for a manual project.")
Expand Down Expand Up @@ -194,19 +163,4 @@ def list_projects(catalog: ProjectCatalog) -> None:
click.echo(table.render())


@click.command()
@click.pass_obj
def roots(catalog: ProjectCatalog) -> None:
"""List registered scan roots."""
for root in catalog.config.load().roots:
click.echo(format_path(root))


@click.command()
@click.pass_obj
def refresh(catalog: ProjectCatalog) -> None:
"""Rescan roots and rebuild the project cache."""
click.echo(f"Found {len(catalog.refresh())} projects.")


project_commands = (project, init, uninit, add, remove, list_projects, roots, refresh)
project_commands = (project, add, remove, list_projects)
113 changes: 113 additions & 0 deletions src/pcd_cli/cli/roots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from __future__ import annotations

from pathlib import Path
from typing import ClassVar, Literal, NamedTuple, TYPE_CHECKING

import click

from pcd_cli.filesystem import canonical_path, format_path
from pcd_cli.models import ExitCode, ProjectSource

if TYPE_CHECKING:
from collections.abc import Sequence

from pcd_cli.catalog import ProjectCatalog
from pcd_cli.models import Project

type RootStatusLabel = Literal["available", "missing"]


class RootListRow(NamedTuple):
path: str
projects: str
status: RootStatusLabel

@classmethod
def from_root(cls, root: Path, projects: Sequence[Project]) -> RootListRow:
resolved_root = canonical_path(root)
project_count = sum(
project.path.is_relative_to(resolved_root)
for project in projects
if project.source is ProjectSource.DISCOVERED
)

return cls(
path=format_path(root),
projects=str(project_count),
status="available" if root.is_dir() else "missing",
)


class RootListTable:
HEADERS: ClassVar[tuple[str, str, str]] = ("PATH", "PROJECTS", "STATUS")

_rows: tuple[RootListRow, ...]

def __init__(self, roots: Sequence[Path], projects: Sequence[Project]) -> None:
self._rows = tuple(RootListRow.from_root(root, projects) for root in roots)

def render(self) -> str:
if not self._rows:
return "No roots found."

table = (self.HEADERS, *self._rows)
columns = zip(*table, strict=True)
widths = tuple(max(len(value) for value in column) for column in columns)

return "\n".join(self._render_row(row, widths) for row in table)

@staticmethod
def _render_row(row: tuple[str, ...], widths: tuple[int, ...]) -> str:
return " ".join(
value.ljust(width) for value, width in zip(row, widths, strict=True)
).rstrip()


@click.command()
@click.pass_obj
def init(catalog: ProjectCatalog) -> None:
"""Register the current directory as a scan root."""
current = Path.cwd()
parent = catalog.find_parent_root(current)
added = catalog.add_scan_root(current)

if not added:
click.echo(f"Already a root: {format_path(current)}")
return

if parent is not None:
click.echo(f"Note: root is inside {format_path(parent)}", err=True)

click.echo(f"Added root: {format_path(current)}")


@click.command()
@click.pass_obj
def uninit(catalog: ProjectCatalog) -> None:
"""Remove the current directory from scan roots."""
current = Path.cwd()
if catalog.remove_scan_root(current):
click.echo(f"Removed root: {format_path(current)}")
return

click.echo("Current directory is not a pcd root.", err=True)
raise click.exceptions.Exit(ExitCode.ERROR)


@click.command()
@click.pass_obj
def roots(catalog: ProjectCatalog) -> None:
"""List registered scan roots with project counts and status."""
settings = catalog.config.load()
table = RootListTable(settings.roots, catalog.projects())
click.echo(table.render())


@click.command()
@click.pass_obj
def refresh(catalog: ProjectCatalog) -> None:
"""Rescan roots and rebuild the project cache."""
click.echo(f"Found {len(catalog.refresh())} projects.")


root_commands = (init, uninit, roots, refresh)
8 changes: 6 additions & 2 deletions tests/integration/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,19 @@ def test_roots_and_refresh_commands(
monkeypatch: pytest.MonkeyPatch,
) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "repo" / ".git").mkdir(parents=True)
monkeypatch.chdir(root)
assert runner.invoke(cli, ["init"]).exit_code == 0

roots = runner.invoke(cli, ["roots"])
refreshed = runner.invoke(cli, ["refresh"])

assert "PATH" in roots.output
assert "PROJECTS" in roots.output
assert "STATUS" in roots.output
assert str(root) in roots.output
assert "Found 0 projects." in refreshed.output
assert roots.output.splitlines()[1].split()[-2:] == ["1", "available"]
assert "Found 1 projects." in refreshed.output


def test_shell_init(runner: CliRunner) -> None:
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_root_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from pcd_cli.cli.roots import RootListTable
from pcd_cli.filesystem import format_path
from pcd_cli.models import Project, ProjectSource

if TYPE_CHECKING:
from pathlib import Path


def test_empty_root_list() -> None:
assert RootListTable((), ()).render() == "No roots found."


def test_root_list_shows_project_counts_and_status(tmp_path: Path) -> None:
available = tmp_path / "available"
repository = available / "repository"
outside = tmp_path / "outside"
missing = tmp_path / "missing"
repository.mkdir(parents=True)
outside.mkdir()
projects = (
Project("repository", repository, repository, ProjectSource.DISCOVERED),
Project("outside", outside, outside, ProjectSource.DISCOVERED),
Project("manual", available / "manual", available / "manual", ProjectSource.MANUAL),
)
available_path = format_path(available)
missing_path = format_path(missing)
path_width = max(len(available_path), len(missing_path), len("PATH"))
expected = "\n".join(
(
f"{'PATH':<{path_width}} PROJECTS STATUS",
f"{available_path:<{path_width}} 1 available",
f"{missing_path:<{path_width}} 0 missing",
)
)

assert RootListTable((available, missing), projects).render() == expected