Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import shlex
  4import subprocess
  5import sys
  6import tomllib
  7from pathlib import Path
  8
  9import click
 10
 11LOCK_FILE = Path("uv.lock")
 12
 13
 14@click.command()
 15@click.argument("packages", nargs=-1)
 16@click.option(
 17    "--diff", is_flag=True, help="Read versions from unstaged uv.lock changes"
 18)
 19@click.option(
 20    "--agent-command",
 21    envvar="PLAIN_UPGRADE_AGENT_COMMAND",
 22    help="Run command with generated prompt",
 23)
 24def cli(
 25    packages: tuple[str, ...], diff: bool, agent_command: str | None = None
 26) -> None:
 27    """Generate an upgrade prompt for plain packages."""
 28    require_git_repo()
 29
 30    if not packages:
 31        click.secho("Getting installed packages...", bold=True, err=True)
 32        packages = tuple(sorted(get_installed_plain_packages()))
 33        for pkg in packages:
 34            click.secho(f"- {click.style(pkg, fg='yellow')}", err=True)
 35        click.echo(err=True)
 36
 37    if not packages:
 38        raise click.UsageError("No plain packages found or specified.")
 39
 40    if diff:
 41        before_after = versions_from_diff(packages)
 42    else:
 43        before_after = upgrade_packages(packages)
 44
 45    # Remove all packages that were not upgraded
 46    before_after = {
 47        pkg: versions
 48        for pkg, versions in before_after.items()
 49        if versions[0] != versions[1]
 50    }
 51
 52    if not before_after:
 53        click.secho(
 54            "No packages were upgraded. If uv.lock has already been updated, use --diff instead.",
 55            fg="green",
 56            err=True,
 57        )
 58        return
 59
 60    prompt = build_prompt(before_after)
 61
 62    if agent_command:
 63        cmd = shlex.split(agent_command)
 64        cmd.append(prompt)
 65        result = subprocess.run(cmd, check=False)
 66        if result.returncode != 0:
 67            click.secho(
 68                f"Agent command failed with exit code {result.returncode}",
 69                fg="red",
 70                err=True,
 71            )
 72    else:
 73        click.secho(
 74            "\nCopy this prompt to a coding agent. To run an agent automatically, use --agent-command or set the PLAIN_UPGRADE_AGENT_COMMAND environment variable.\n",
 75            dim=True,
 76            italic=True,
 77            err=True,
 78        )
 79        click.echo(prompt)
 80
 81
 82def require_git_repo() -> None:
 83    result = subprocess.run(
 84        ["git", "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True
 85    )
 86    if result.returncode != 0 or result.stdout.strip() != "true":
 87        raise click.UsageError("This command must be run inside a git repository")
 88
 89
 90def get_installed_plain_packages() -> list[str]:
 91    lock_text = LOCK_FILE.read_text()
 92    data = tomllib.loads(lock_text)
 93    names: list[str] = []
 94    for pkg in data.get("package", []):
 95        name = pkg.get("name", "")
 96        if name.startswith("plain") and name != "plain-upgrade":
 97            names.append(name)
 98    return names
 99
100
101def parse_lock_versions(lock_text: str, packages: set[str]) -> dict[str, str]:
102    data = tomllib.loads(lock_text)
103    versions: dict[str, str] = {}
104    for pkg in data.get("package", []):
105        name = pkg.get("name")
106        if name in packages:
107            versions[name] = pkg.get("version")
108    return versions
109
110
111def versions_from_diff(
112    packages: tuple[str, ...],
113) -> dict[str, tuple[str | None, str | None]]:
114    result = subprocess.run(
115        ["git", "status", "--porcelain", str(LOCK_FILE)], capture_output=True, text=True
116    )
117    if not result.stdout.strip():
118        raise click.UsageError(
119            "--diff specified but uv.lock has no uncommitted changes"
120        )
121
122    prev_text = subprocess.run(
123        ["git", "show", f"HEAD:{LOCK_FILE}"], capture_output=True, text=True, check=True
124    ).stdout
125    current_text = LOCK_FILE.read_text()
126
127    packages_set = set(packages)
128    before = parse_lock_versions(prev_text, packages_set)
129    after = parse_lock_versions(current_text, packages_set)
130
131    return {pkg: (before.get(pkg), after.get(pkg)) for pkg in packages}
132
133
134def upgrade_packages(
135    packages: tuple[str, ...],
136) -> dict[str, tuple[str | None, str | None]]:
137    before = parse_lock_versions(LOCK_FILE.read_text(), set(packages))
138
139    upgrade_args = ["uv", "sync"]
140    for pkg in packages:
141        upgrade_args.extend(["--upgrade-package", pkg])
142
143    click.secho("Upgrading with uv sync...", bold=True, err=True)
144    subprocess.run(upgrade_args, check=True, stdout=sys.stderr)
145    click.echo(err=True)
146
147    after = parse_lock_versions(LOCK_FILE.read_text(), set(packages))
148    return {pkg: (before.get(pkg), after.get(pkg)) for pkg in packages}
149
150
151def build_prompt(before_after: dict[str, tuple[str | None, str | None]]) -> str:
152    lines = [
153        "These packages have been updated and may require additional changes to the code:",
154        "",
155    ]
156    for pkg, (before, after) in before_after.items():
157        lines.append(f"- {pkg}: {before} -> {after}")
158    lines.extend(
159        [
160            "",
161            'For each package run `plain-changelog {package} --from {before} --to {after}` and read the "Upgrade instructions" for what to do.',
162            "Work through each package in order, making all necessary code changes (if applicable).",
163            "When ALL of the package upgrade steps have been completed, you can check the results with `plain preflight` and `plain test`.",
164            "Do not commit any changes.",
165        ]
166    )
167    return "\n".join(lines)