diff --git a/AGENTS.md b/AGENTS.md index 0a48745d..0c863e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,30 @@ with `invoke_without_command=True` for consistent "did you mean?" suggestions. Add an explicit `click.echo(ctx.get_help())` in the group callback when `ctx.invoked_subcommand is None` for help display. +## Rust Port Workflow + +The CLI is being ported from Python to Rust incrementally. The shipped +binary is `mergify` (built from `crates/mergify-cli`); commands not yet +ported fall through to a Python shim implemented by the +`crates/mergify-py-shim` crate, which invokes `python -m mergify_cli` +on the bundled Python source. Native Rust commands are dispatched +directly. Drift between the two implementations is prevented +structurally: when porting a command, the Python implementation MUST +be deleted in the same PR that adds the Rust implementation. There is +no period where both copies coexist. + +A single PR therefore contains: + +1. The Rust implementation (in the relevant `crates/*` crate) plus tests. +2. Removal of the Python implementation file(s) and their tests. +3. Any wiring updates (click registration, shim allow-list, etc.). + +Reviewers should reject PRs that port a command without removing the +Python copy. Removing the Python copy without a Rust replacement is +fine when the command is being deprecated/dropped from the CLI — the +rule is "no two live copies of the same command", not "every Python +copy must be replaced". + ## Documentation When adding or changing a CLI feature, always update the documentation: diff --git a/PORT_STATUS.toml b/PORT_STATUS.toml deleted file mode 100644 index 3cac3cbf..00000000 --- a/PORT_STATUS.toml +++ /dev/null @@ -1,136 +0,0 @@ -# Port inventory for the mergify CLI Rust port. -# -# Every click subcommand exposed by the Python CLI must appear here. -# The inventory test in mergify_cli/tests/test_port_status.py walks -# click's command tree and fails if it finds a command that isn't -# listed, or an entry here that doesn't match any Python command. -# -# Status values: -# "native" — handled by the Rust binary's native dispatch. -# "shimmed" — handled by Python via the py-shim crate. -# -# Workflow -# -------- -# -# When adding a new Python subcommand: -# Add an entry here with status = "shimmed" in the same PR. -# -# When porting a command to Rust: -# Flip status from "shimmed" to "native" in the same PR that adds -# the Rust dispatch + tests. -# -# When removing a command: -# Drop the entry here in the same PR that removes the Python -# implementation. -# -# The guard fires before anything else, so forgetting to update -# this file surfaces as a CI failure rather than a silent unshipped -# port. - -[[command]] -path = ["ci", "git-refs"] -status = "shimmed" - -[[command]] -path = ["ci", "junit-process"] -status = "shimmed" - -[[command]] -path = ["ci", "junit-upload"] -status = "shimmed" - -[[command]] -path = ["ci", "queue-info"] -status = "shimmed" - -[[command]] -path = ["ci", "scopes"] -status = "shimmed" - -[[command]] -path = ["freeze", "create"] -status = "shimmed" - -[[command]] -path = ["freeze", "delete"] -status = "shimmed" - -[[command]] -path = ["freeze", "list"] -status = "shimmed" - -[[command]] -path = ["freeze", "update"] -status = "shimmed" - -[[command]] -path = ["queue", "pause"] -status = "shimmed" - -[[command]] -path = ["queue", "show"] -status = "shimmed" - -[[command]] -path = ["queue", "status"] -status = "shimmed" - -[[command]] -path = ["queue", "unpause"] -status = "shimmed" - -[[command]] -path = ["stack", "checkout"] -status = "shimmed" - -[[command]] -path = ["stack", "edit"] -status = "shimmed" - -[[command]] -path = ["stack", "fixup"] -status = "shimmed" - -[[command]] -path = ["stack", "hooks"] -status = "shimmed" - -[[command]] -path = ["stack", "list"] -status = "shimmed" - -[[command]] -path = ["stack", "move"] -status = "shimmed" - -[[command]] -path = ["stack", "new"] -status = "shimmed" - -[[command]] -path = ["stack", "note"] -status = "shimmed" - -[[command]] -path = ["stack", "open"] -status = "shimmed" - -[[command]] -path = ["stack", "push"] -status = "shimmed" - -[[command]] -path = ["stack", "reorder"] -status = "shimmed" - -[[command]] -path = ["stack", "setup"] -status = "shimmed" - -[[command]] -path = ["stack", "squash"] -status = "shimmed" - -[[command]] -path = ["stack", "sync"] -status = "shimmed" diff --git a/mergify_cli/tests/test_port_status.py b/mergify_cli/tests/test_port_status.py deleted file mode 100644 index 6ea1ffc7..00000000 --- a/mergify_cli/tests/test_port_status.py +++ /dev/null @@ -1,158 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -"""Port inventory guard. - -Walks the click command tree exposed by ``mergify_cli.cli.cli`` and -compares it to the inventory in ``PORT_STATUS.toml``. Any mismatch -is a CI failure. - -The intent is to prevent a Python command from being added while -the Rust port is in flight without someone explicitly deciding -whether it ships via the shim (``status = "shimmed"``) or via a -native Rust implementation (``status = "native"``). Forgetting to -port a new command therefore surfaces immediately rather than -getting noticed months later when users report missing -functionality in the static binary. -""" - -from __future__ import annotations - -import pathlib -import tomllib - -import click - -from mergify_cli.cli import cli as _cli - - -_VALID_STATUSES: frozenset[str] = frozenset({"native", "shimmed"}) -_PORT_STATUS_PATH = ( - pathlib.Path(__file__).resolve().parent.parent.parent / "PORT_STATUS.toml" -) - - -def _walk_commands( - cmd: click.Command, - prefix: tuple[str, ...] = (), -) -> list[tuple[str, ...]]: - """Collect the path of every leaf command reachable from ``cmd``. - - Groups contribute nothing themselves — only their leaf - subcommands appear. Empty prefixes mean "the root `mergify` - command invoked without a subcommand", which we don't track. - """ - if isinstance(cmd, click.Group): - paths: list[tuple[str, ...]] = [] - for name, child in sorted(cmd.commands.items()): - paths.extend(_walk_commands(child, (*prefix, name))) - return paths - return [prefix] if prefix else [] - - -def _discovered_commands() -> set[tuple[str, ...]]: - return set(_walk_commands(_cli)) - - -def _load_port_status() -> list[dict[str, object]]: - text = _PORT_STATUS_PATH.read_text(encoding="utf-8") - data = tomllib.loads(text) - commands = data.get("command", []) - assert isinstance(commands, list), ( - "PORT_STATUS.toml must define `command` as an array of tables " - "using `[[command]]`, not a single table `[command]`." - ) - assert all(isinstance(entry, dict) for entry in commands), ( - "PORT_STATUS.toml `command` entries must each be tables defined " - "with `[[command]]`." - ) - return commands - - -def _declared_commands() -> set[tuple[str, ...]]: - return {tuple(entry["path"]) for entry in _load_port_status()} # type: ignore[arg-type] - - -def test_every_python_command_is_in_port_status() -> None: - """Every click command exposed by the Python CLI must appear in - PORT_STATUS.toml.""" - discovered = _discovered_commands() - declared = _declared_commands() - - missing = discovered - declared - assert not missing, ( - "\nThese click commands exist in mergify_cli but are not listed " - "in PORT_STATUS.toml:\n" - + "\n".join(f" - {' '.join(path)}" for path in sorted(missing)) - + '\n\nAdd each as `status = "shimmed"` (or `status = "native"` ' - "if already ported) so the Rust port doesn't forget them." - ) - - -def test_no_stale_entries_in_port_status() -> None: - """Every entry in PORT_STATUS.toml must correspond to a live - click command.""" - discovered = _discovered_commands() - declared = _declared_commands() - - extra = declared - discovered - assert not extra, ( - "\nThese entries in PORT_STATUS.toml do not match any " - "click command:\n" - + "\n".join(f" - {' '.join(path)}" for path in sorted(extra)) - + "\n\nRemove the stale entries (the command was renamed or " - "deleted)." - ) - - -def test_port_status_uses_only_valid_status_values() -> None: - """Every entry must use a known status value.""" - for entry in _load_port_status(): - # Validate required keys here so a typo in `path` or `status` - # surfaces with a targeted assertion message instead of a - # bare KeyError traceback. - assert "path" in entry, ( - f"PORT_STATUS.toml entry {entry!r} is missing required key 'path'" - ) - assert "status" in entry, ( - f"PORT_STATUS.toml entry {entry!r} is missing required key 'status'" - ) - path = entry["path"] - assert isinstance(path, list), ( - f"PORT_STATUS.toml entry {entry!r}: 'path' must be a list" - ) - assert all(isinstance(p, str) for p in path), ( - f"PORT_STATUS.toml entry {entry!r}: every 'path' segment must be a string" - ) - status = entry["status"] - assert status in _VALID_STATUSES, ( - f"PORT_STATUS.toml entry for {path!r} uses invalid " - f"status {status!r}; valid values are " - f"{sorted(_VALID_STATUSES)}" - ) - - -def test_port_status_entries_have_exactly_path_and_status_keys() -> None: - """Catches typos like `stats` or accidentally adding a third - undocumented key.""" - allowed = {"path", "status"} - for entry in _load_port_status(): - actual = set(entry.keys()) - missing = allowed - actual - extras = actual - allowed - assert actual == allowed, ( - f"PORT_STATUS.toml entry {entry!r} must have exactly keys " - f"{sorted(allowed)}; missing keys: {sorted(missing)}, " - f"unexpected keys: {sorted(extras)}." - )