From be738c0afcafed48b636390d2150f60504d1a10f Mon Sep 17 00:00:00 2001 From: Vyacheslav Dmitriev Date: Sat, 5 Sep 2026 12:43:46 +0300 Subject: [PATCH 1/2] refactor(cli): separate root commands from project commands --- src/pcd_cli/cli/__init__.py | 3 +- src/pcd_cli/cli/app.py | 2 ++ src/pcd_cli/cli/projects.py | 48 +---------------------------- src/pcd_cli/cli/roots.py | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 src/pcd_cli/cli/roots.py diff --git a/src/pcd_cli/cli/__init__.py b/src/pcd_cli/cli/__init__.py index 129c20e..8f64ee5 100644 --- a/src/pcd_cli/cli/__init__.py +++ b/src/pcd_cli/cli/__init__.py @@ -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, diff --git a/src/pcd_cli/cli/app.py b/src/pcd_cli/cli/app.py index c2d1c92..abfb5c3 100644 --- a/src/pcd_cli/cli/app.py +++ b/src/pcd_cli/cli/app.py @@ -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 @@ -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, diff --git a/src/pcd_cli/cli/projects.py b/src/pcd_cli/cli/projects.py index 3790796..05f6438 100644 --- a/src/pcd_cli/cli/projects.py +++ b/src/pcd_cli/cli/projects.py @@ -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.") @@ -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) diff --git a/src/pcd_cli/cli/roots.py b/src/pcd_cli/cli/roots.py new file mode 100644 index 0000000..435d762 --- /dev/null +++ b/src/pcd_cli/cli/roots.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import click + +from pcd_cli.filesystem import format_path +from pcd_cli.models import ExitCode + +if TYPE_CHECKING: + from pcd_cli.catalog import ProjectCatalog + + +@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.""" + 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.") + + +root_commands = (init, uninit, roots, refresh) From 40d90a7de45e4960ec0f1c6c85cacf8f17f93d02 Mon Sep 17 00:00:00 2001 From: Vyacheslav Dmitriev Date: Sat, 5 Sep 2026 12:45:01 +0300 Subject: [PATCH 2/2] feat(cli): show project counts and status for roots --- ROADMAP.txt | 1 - src/pcd_cli/cli/roots.py | 64 +++++++++++++++++++++++--- tests/integration/test_cli_commands.py | 8 +++- tests/unit/test_root_list.py | 40 ++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_root_list.py diff --git a/ROADMAP.txt b/ROADMAP.txt index 1c31907..4f9b907 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -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 ` with project metadata and status. Handle duplicate project names explicitly. diff --git a/src/pcd_cli/cli/roots.py b/src/pcd_cli/cli/roots.py index 435d762..c95c9a0 100644 --- a/src/pcd_cli/cli/roots.py +++ b/src/pcd_cli/cli/roots.py @@ -1,15 +1,66 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import ClassVar, Literal, NamedTuple, TYPE_CHECKING import click -from pcd_cli.filesystem import format_path -from pcd_cli.models import ExitCode +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() @@ -46,9 +97,10 @@ def uninit(catalog: ProjectCatalog) -> None: @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)) + """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() diff --git a/tests/integration/test_cli_commands.py b/tests/integration/test_cli_commands.py index 08032ce..36a1120 100644 --- a/tests/integration/test_cli_commands.py +++ b/tests/integration/test_cli_commands.py @@ -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: diff --git a/tests/unit/test_root_list.py b/tests/unit/test_root_list.py new file mode 100644 index 0000000..1dc3ddf --- /dev/null +++ b/tests/unit/test_root_list.py @@ -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