From e013f2f459fa28f605ea8a766f81445f33a3e9bc Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 22 May 2026 17:53:58 +0300 Subject: [PATCH 1/2] Add git auto-commit by default for create/add Initialise a git repo on create and commit the result of every template run, restoring bobtemplates.plone's auto-commit behaviour. Opt out per run with --no-git or globally via auto_commit=false in ~/.plonecli/config.toml. Warn and prompt (default: cancel) before running create/add/setup on a repo with uncommitted changes; non-interactive runs (--defaults or no tty) print the warning but proceed. --- CHANGES.md | 12 +- plonecli/cli.py | 81 +++++++++- plonecli/config.py | 6 + plonecli/git.py | 155 +++++++++++++++++++ plonecli/skills/plonecli/SKILL.md | 2 +- plonecli/skills/plonecli/reference/add.md | 3 +- plonecli/skills/plonecli/reference/create.md | 2 +- plonecli/templates.py | 27 +++- tests/test_config.py | 12 ++ tests/test_git.py | 124 +++++++++++++++ tests/test_plonecli.py | 155 +++++++++++++++++++ 11 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 plonecli/git.py create mode 100644 tests/test_git.py diff --git a/CHANGES.md b/CHANGES.md index dec5ff9..706c8a4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,17 @@ ## 7.0.0b8 (unreleased) -- Nothing changed yet. +- Auto-commit by default: `plonecli create` initialises a git repo and commits + the generated package, and `plonecli add` commits each subtemplate run. Opt + out per run with `--no-git` or globally via `auto_commit = false` in the + `[git]` section of `~/.plonecli/config.toml`. + [MrTango] + + +- Warn and prompt before running `create`/`add`/`setup` on a git repository + with uncommitted changes; the prompt defaults to cancel. Non-interactive runs + (`--defaults` or no tty) print the warning but proceed. + [MrTango] ## 7.0.0b7 (2026-05-22) diff --git a/plonecli/cli.py b/plonecli/cli.py index 0be0acb..10c2160 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -26,6 +26,36 @@ def echo(msg, fg="green", reverse=False): click.echo(click.style(msg, fg=fg, reverse=reverse)) +def _is_interactive(): + """Whether we can prompt the user (stdin is a terminal).""" + return sys.stdin.isatty() + + +def confirm_clean_git(target_dir, defaults: bool) -> bool: + """Warn and ask to continue if the target repo has uncommitted changes. + + Returns True to proceed, False if the user cancels. The prompt defaults to + *cancel* so an accidental Enter is safe. In non-interactive mode (``--defaults`` + or no tty) the warning is printed but the run proceeds without prompting. + """ + from plonecli.git import dirty_files + + modified, untracked = dirty_files(target_dir) + if not modified and not untracked: + return True + + echo("\nWARNING: the git repository has uncommitted changes:", fg="yellow") + for f in modified: + echo(f" modified: {f}", fg="yellow") + for f in untracked: + echo(f" untracked: {f}", fg="yellow") + echo("", fg="yellow") + + if defaults or not _is_interactive(): + return True + return click.confirm("Continue anyway?", default=False) + + def _parse_data(pairs): """Parse ``KEY=VALUE`` strings into a dict of copier answers. @@ -210,8 +240,14 @@ def format_help(self, ctx, formatter): help="Use template defaults for unanswered questions instead of prompting " "(non-interactive).", ) +@click.option( + "--no-git", + "no_git", + is_flag=True, + help="Do not initialise git or auto-commit the generated package.", +) @click.pass_context -def create(context, template, name, data, data_file, defaults): +def create(context, template, name, data, data_file, defaults, no_git): """Create a new Plone package""" config = context.obj["config"] reg = TemplateRegistry(config) @@ -224,16 +260,31 @@ def create(context, template, name, data, data_file, defaults): possibilities=reg.get_main_templates(), ) + if not confirm_clean_git(name, defaults): + echo("Aborted.", fg="yellow") + return + + git_commit = config.auto_commit and not no_git answers = _collect_data(data_file, data) steps = reg.get_composite_steps(resolved) if steps: echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True) for step in steps: echo(f"\n Applying template: {step}", fg="green") - run_create(step, name, config, data=answers, defaults=defaults) + committed = run_create( + step, name, config, data=answers, defaults=defaults, + git_commit=git_commit, + ) + if committed: + echo(f" Committed: {committed}", fg="green") else: echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True) - run_create(resolved, name, config, data=answers, defaults=defaults) + committed = run_create( + resolved, name, config, data=answers, defaults=defaults, + git_commit=git_commit, + ) + if committed: + echo(f" Committed: {committed}", fg="green") context.obj["target_dir"] = name @@ -259,8 +310,14 @@ def create(context, template, name, data, data_file, defaults): help="Use template defaults for unanswered questions instead of prompting " "(non-interactive).", ) +@click.option( + "--no-git", + "no_git", + is_flag=True, + help="Do not auto-commit the changes made by this subtemplate.", +) @click.pass_context -def add(context, template, data, data_file, defaults): +def add(context, template, data, data_file, defaults, no_git): """Add features to your existing Plone package""" project = context.obj.get("project") if project is None: @@ -277,9 +334,19 @@ def add(context, template, data, data_file, defaults): possibilities=reg.get_subtemplates(), ) + if not confirm_clean_git(project.root_folder, defaults): + echo("Aborted.", fg="yellow") + return + + git_commit = config.auto_commit and not no_git answers = _collect_data(data_file, data) echo(f"\nAdding {resolved} to {project.root_folder.name}", fg="green", reverse=True) - run_add(resolved, project, config, data=answers, defaults=defaults) + committed = run_add( + resolved, project, config, data=answers, defaults=defaults, + git_commit=git_commit, + ) + if committed: + echo(f" Committed: {committed}", fg="green") @cli.command() @@ -294,6 +361,10 @@ def setup(context): "The 'setup' command can only be run inside a backend_addon project." ) + if not confirm_clean_git(project.root_folder, defaults=False): + echo("Aborted.", fg="yellow") + return + config = context.obj["config"] echo("\nRunning zope-setup...", fg="green", reverse=True) run_create("zope-setup", str(project.root_folder), config) diff --git a/plonecli/config.py b/plonecli/config.py index 563a3e1..953765f 100644 --- a/plonecli/config.py +++ b/plonecli/config.py @@ -30,6 +30,7 @@ class PlonecliConfig: repo_url: str = DEFAULT_REPO_URL repo_branch: str = DEFAULT_BRANCH templates_dir: str = str(TEMPLATES_DIR) + auto_commit: bool = True def load_config() -> PlonecliConfig: @@ -50,6 +51,7 @@ def load_config() -> PlonecliConfig: author = data.get("author", {}) defaults = data.get("defaults", {}) templates = data.get("templates", {}) + git = data.get("git", {}) config.author_name = author.get("name", config.author_name) config.author_email = author.get("email", config.author_email) @@ -58,6 +60,7 @@ def load_config() -> PlonecliConfig: config.repo_url = templates.get("repo_url", config.repo_url) config.repo_branch = templates.get("branch", config.repo_branch) config.templates_dir = templates.get("local_path", config.templates_dir) + config.auto_commit = git.get("auto_commit", config.auto_commit) # Environment variables override config file if os.environ.get(ENV_REPO_URL): @@ -90,6 +93,9 @@ def save_config(config: PlonecliConfig) -> None: repo_url = "{config.repo_url}" branch = "{config.repo_branch}" local_path = "{config.templates_dir}" + +[git] +auto_commit = {str(config.auto_commit).lower()} """ CONFIG_FILE.write_text(content) diff --git a/plonecli/git.py b/plonecli/git.py new file mode 100644 index 0000000..f5d7f32 --- /dev/null +++ b/plonecli/git.py @@ -0,0 +1,155 @@ +"""Auto-commit support for generated packages. + +plonecli initialises a git repository in every generated package and commits +after each template run, so the package always has a reviewable history and +subtemplate runs are easy to inspect or revert. This mirrors the auto-commit +behaviour of the legacy ``bobtemplates.plone``. Users can opt out per run with +``--no-git`` or globally via the ``auto_commit`` config setting. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from plonecli.config import PlonecliConfig + + +def is_git_repo(path: Path) -> bool: + """Return True if ``path`` is inside a git working tree.""" + try: + subprocess.run( + ["git", "rev-parse", "--git-dir"], + cwd=str(path), + check=True, + capture_output=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def dirty_files(path: str | Path) -> tuple[list[str], list[str]]: + """Return ``(modified, untracked)`` paths for the git repo at ``path``. + + Both lists are empty when the working tree is clean or ``path`` is not a + git repository. + """ + path = Path(path) + if not is_git_repo(path): + return [], [] + + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=str(path), + capture_output=True, + text=True, + ) + modified: list[str] = [] + untracked: list[str] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + code = line[:2] + name = line[3:] + if code.startswith("?"): + untracked.append(name) + else: + modified.append(name) + return modified, untracked + + +def _has_identity(path: Path) -> bool: + """Return True if git has both user.name and user.email configured.""" + for key in ("user.name", "user.email"): + result = subprocess.run( + ["git", "config", key], + cwd=str(path), + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + return False + return True + + +def _nothing_staged(path: Path) -> bool: + """Return True if the index has no staged changes to commit.""" + result = subprocess.run( + ["git", "diff", "--cached", "--quiet"], + cwd=str(path), + ) + return result.returncode == 0 + + +def commit_template_changes( + target_dir: str | Path, + template_name: str, + config: PlonecliConfig, + *, + is_subtemplate: bool, +) -> str | None: + """Initialise git (if needed) and commit the result of a template run. + + Args: + target_dir: The generated/updated package directory. + template_name: Canonical template name, used in the commit message. + config: Global plonecli config (provides the identity fallback). + is_subtemplate: Whether this was an ``add`` (subtemplate) run. + + Returns: + The commit message if a commit was made, otherwise ``None`` (nothing to + commit). Git failures are swallowed with a warning so a generated + package is never lost to a git problem. + """ + target = Path(target_dir) + if not target.exists(): + return None + + try: + just_initialized = False + if not is_git_repo(target): + subprocess.run( + ["git", "init"], + cwd=str(target), + check=True, + capture_output=True, + ) + just_initialized = True + + subprocess.run( + ["git", "add", "-A"], + cwd=str(target), + check=True, + capture_output=True, + ) + + if _nothing_staged(target): + return None + + if is_subtemplate: + message = f"Add {template_name} subtemplate" + elif just_initialized: + message = f"Create package with {template_name} template" + else: + message = f"Add {template_name} template" + + commit_cmd = ["git"] + if not _has_identity(target): + commit_cmd += [ + "-c", + f"user.name={config.author_name}", + "-c", + f"user.email={config.author_email}", + ] + commit_cmd += ["commit", "-m", message] + subprocess.run( + commit_cmd, + cwd=str(target), + check=True, + capture_output=True, + ) + return message + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + print(f"Warning: skipped git auto-commit ({exc}).") + return None diff --git a/plonecli/skills/plonecli/SKILL.md b/plonecli/skills/plonecli/SKILL.md index 0ae5e84..2f3bd6f 100644 --- a/plonecli/skills/plonecli/SKILL.md +++ b/plonecli/skills/plonecli/SKILL.md @@ -50,7 +50,7 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone - **Profile XML changes need an upgrade step — scaffold it automatically.** Whenever you edit GenericSetup profile XML under `profiles/default/` (e.g. `catalog.xml`, `types/*.xml`, `types.xml`, `workflows.xml`, `registry.xml`, `rolemap.xml`) in a way that must propagate to already-installed sites, run `plonecli add upgrade_step --defaults -d upgrade_step_title=""` as part of the same change — don't leave it to the user to remember. It bumps `profiles/default/metadata.xml` and registers a GS upgrade handler; then fill that handler so existing sites actually get the change (reapply the relevant import step or migrate data). Never hand-edit `metadata.xml`'s version to "do an upgrade" — that bumps the number without a registered step. Details and what does/doesn't need a step: [reference/add.md](reference/add.md). - **Don't recreate to change settings.** Re-running `create` over an existing project is wrong; use the reconfigure flow ([reference/maintain.md](reference/maintain.md)). - **Old/legacy package: adapt the structure minimally, never hand-roll old-style files.** If `plonecli add` won't wire features into an old package (mr.bob/`bobtemplates.plone`, buildout, `setup.py` — typically missing `[tool.plone.backend_addon.settings]` or a `src//configure.zcml`), don't fall back to writing the subtemplate's files by hand, and don't re-run the `backend_addon` template over it (it overwrites `__init__.py` and other real code). Inspect what's there, then make only the minimal edits the subtemplate hooks need to function — chiefly the `[tool.plone.backend_addon.settings]` block (so plonecli detects the addon and can register subtemplates) and a stub `src//configure.zcml` if absent (hooks append ``s before `` and silently skip it when the file is missing). Preserve existing code; recommend but don't force broader modernization. Then run `plonecli add` normally. **A migrated package should also end up with the same complete, working `tasks.py` a freshly generated one has — but `tasks.py` comes from the `zope-setup` layer, so never hand-write it: if there's no compatible zope-setup yet, run `plonecli setup` to get it (that lays down the package-fitting `tasks.py`); if a zope-setup exists with a stale `tasks.py`, regenerate via `uv run invoke reconfigure --target=zope-setup`.** Details: [reference/migrate.md](reference/migrate.md). -- After `create`/`add`/reconfigure, generated files change — review `git status`/diff and preserve intentional local edits. +- `create` and `add` auto-commit by default (`create` also `git init`s the package); review with `git log`/`git show`. Any uncommitted local edits get swept into that commit, so commit/stash your work first, or pass `--no-git` to skip the commit. On a dirty repo, `create`/`add`/`setup` warn and prompt to continue (default: cancel); `--defaults` (or no tty) skips the prompt and proceeds after the warning. `reconfigure` does not commit — review its changes with `git status`/diff and preserve intentional local edits. ## Quick start diff --git a/plonecli/skills/plonecli/reference/add.md b/plonecli/skills/plonecli/reference/add.md index c25c6f7..6bae952 100644 --- a/plonecli/skills/plonecli/reference/add.md +++ b/plonecli/skills/plonecli/reference/add.md @@ -18,6 +18,7 @@ By default `add` (and `create`) drop into copier's **interactive prompts**, whic - `--defaults` — answer every question from the template's defaults (no prompts). - `-d/--data KEY=VALUE` — pre-fill a specific answer (repeatable); overrides the default and skips that prompt. - `--data-file PATH` — load answers from a YAML/JSON file (handy for many answers); inline `-d` overrides matching keys. +- `--no-git` — skip the automatic commit (see "After adding"). By default `add` commits the subtemplate's changes. ```shell # fully non-interactive: defaults for everything, override the few you care about @@ -49,7 +50,7 @@ copier asks for the specifics (names, fields, options). In Claude Code / CI you ## After adding -1. Review `git status`/diff — `add` writes new files and may touch existing ones (e.g. `configure.zcml`, `profiles`). Preserve intentional local edits. +1. `add` auto-commits its changes (new files plus edits to existing ones like `configure.zcml`, `profiles`). Review the commit with `git show`/`git log`. Note: any *uncommitted* local edits in the package are swept into that commit too — so if the repo is dirty, `add` first **warns and prompts to continue (default: cancel)**; commit or stash your own work first, or pass `--no-git` to leave everything staged for manual review. With `--defaults` (or no tty) the prompt is skipped and the run proceeds after the warning. 2. Run `plonecli test` and report real results. Never skip tests. 3. If a running instance is needed to see the change, ask the user to (re)start it — do not start the server yourself. diff --git a/plonecli/skills/plonecli/reference/create.md b/plonecli/skills/plonecli/reference/create.md index 385c534..d617dfe 100644 --- a/plonecli/skills/plonecli/reference/create.md +++ b/plonecli/skills/plonecli/reference/create.md @@ -40,7 +40,7 @@ The `tasks.py` for `invoke` (which drives `serve`/`test`/`debug`/`reconfigure`) 1. `cd` into the new project directory. 2. Add features with `plonecli add ...` ([add.md](add.md)). 3. Run `plonecli test` and report results — do not skip tests. -4. Review `git status` to see everything that was generated. +4. `create` initialises a git repo and commits the generated package (one commit per template; the `addon` composite makes two). Review it with `git log`/`git show`. Pass `--no-git` to skip both the init and the commit. 5. Do **not** auto-start the server; if a running instance is needed, ask the user. ## Adding a Zope instance to an existing addon diff --git a/plonecli/templates.py b/plonecli/templates.py index 312f173..71d20eb 100644 --- a/plonecli/templates.py +++ b/plonecli/templates.py @@ -8,6 +8,7 @@ from copier import run_copy from plonecli.config import PlonecliConfig +from plonecli.git import commit_template_changes from plonecli.project import ProjectContext @@ -135,7 +136,8 @@ def run_create( config: PlonecliConfig, data: dict | None = None, defaults: bool = False, -) -> None: + git_commit: bool = True, +) -> str | None: """Run copier to create a new project from a main template. Args: @@ -145,6 +147,10 @@ def run_create( data: Optional answers to pre-fill (skips interactive prompts for these). defaults: Use template defaults for unanswered questions instead of prompting (non-interactive mode). + git_commit: Initialise git (if needed) and commit the result. + + Returns: + The commit message if a commit was made, otherwise ``None``. """ ensure_templates_cloned(config) src = str(get_template_path(template_name, config)) @@ -158,6 +164,12 @@ def run_create( unsafe=True, ) + if git_commit: + return commit_template_changes( + target_name, template_name, config, is_subtemplate=False + ) + return None + def run_add( template_name: str, @@ -165,7 +177,8 @@ def run_add( config: PlonecliConfig, data: dict | None = None, defaults: bool = False, -) -> None: + git_commit: bool = True, +) -> str | None: """Run copier to add a subtemplate to an existing project. Args: @@ -175,6 +188,10 @@ def run_add( data: Optional extra answers. defaults: Use template defaults for unanswered questions instead of prompting (non-interactive mode). + git_commit: Initialise git (if needed) and commit the result. + + Returns: + The commit message if a commit was made, otherwise ``None``. """ ensure_templates_cloned(config) src = str(get_template_path(template_name, config)) @@ -196,3 +213,9 @@ def run_add( defaults=defaults, unsafe=True, ) + + if git_commit: + return commit_template_changes( + project.root_folder, template_name, config, is_subtemplate=True + ) + return None diff --git a/tests/test_config.py b/tests/test_config.py index 5b4a476..8a986de 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,6 +18,18 @@ def test_default_config(): assert config.plone_version == "" assert "plone/copier-templates" in config.repo_url assert config.repo_branch == "main" + assert config.auto_commit is True + + +def test_auto_commit_load_save_roundtrip(tmp_path, monkeypatch): + config_dir = tmp_path / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + save_config(PlonecliConfig(auto_commit=False)) + assert "auto_commit = false" in config_file.read_text() + assert load_config().auto_commit is False def test_load_config_missing_file(tmp_path, monkeypatch): diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..7cb191d --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,124 @@ +"""Tests for plonecli.git auto-commit support.""" + +import subprocess + +from plonecli.config import PlonecliConfig +from plonecli.git import commit_template_changes, dirty_files, is_git_repo + + +def _log(path): + return subprocess.run( + ["git", "log", "--pretty=%s"], + cwd=str(path), + capture_output=True, + text=True, + ).stdout.strip().splitlines() + + +def test_commit_initializes_repo_and_commits(tmp_path): + """A fresh package dir gets git init + an initial commit.""" + (tmp_path / "setup.py").write_text("# generated\n") + config = PlonecliConfig() + + msg = commit_template_changes( + tmp_path, "backend_addon", config, is_subtemplate=False + ) + + assert msg == "Create package with backend_addon template" + assert is_git_repo(tmp_path) + assert _log(tmp_path) == ["Create package with backend_addon template"] + + +def test_commit_subtemplate_message(tmp_path): + """Adding a subtemplate to an existing repo uses the 'Add ... subtemplate' message.""" + config = PlonecliConfig() + (tmp_path / "a.py").write_text("a\n") + commit_template_changes(tmp_path, "backend_addon", config, is_subtemplate=False) + + (tmp_path / "b.py").write_text("b\n") + msg = commit_template_changes(tmp_path, "behavior", config, is_subtemplate=True) + + assert msg == "Add behavior subtemplate" + assert _log(tmp_path) == [ + "Add behavior subtemplate", + "Create package with backend_addon template", + ] + + +def test_commit_noop_when_nothing_changed(tmp_path): + """A second run with no file changes makes no commit.""" + config = PlonecliConfig() + (tmp_path / "a.py").write_text("a\n") + commit_template_changes(tmp_path, "backend_addon", config, is_subtemplate=False) + + msg = commit_template_changes(tmp_path, "behavior", config, is_subtemplate=True) + + assert msg is None + assert len(_log(tmp_path)) == 1 + + +def test_commit_uses_config_identity_fallback(tmp_path, monkeypatch): + """When the repo has no git identity, the config author is used.""" + # Isolate from any machine-global git identity so the fallback is exercised. + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "empty-global")) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(tmp_path / "empty-system")) + for var in ( + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + ): + monkeypatch.delenv(var, raising=False) + + (tmp_path / "a.py").write_text("a\n") + subprocess.run(["git", "init"], cwd=str(tmp_path), check=True, capture_output=True) + # No user.name/user.email configured for this repo. + config = PlonecliConfig(author_name="Jane Dev", author_email="jane@example.com") + + commit_template_changes(tmp_path, "backend_addon", config, is_subtemplate=False) + + author = subprocess.run( + ["git", "log", "-1", "--pretty=%an <%ae>"], + cwd=str(tmp_path), + capture_output=True, + text=True, + ).stdout.strip() + assert author == "Jane Dev " + + +def test_commit_returns_none_for_missing_dir(tmp_path): + config = PlonecliConfig() + assert ( + commit_template_changes( + tmp_path / "nope", "backend_addon", config, is_subtemplate=False + ) + is None + ) + + +def test_is_git_repo_false_for_plain_dir(tmp_path): + assert is_git_repo(tmp_path) is False + + +def test_dirty_files_clean_or_non_repo(tmp_path): + # Not a repo at all. + assert dirty_files(tmp_path) == ([], []) + + # Clean repo after committing everything. + config = PlonecliConfig() + (tmp_path / "a.py").write_text("a\n") + commit_template_changes(tmp_path, "backend_addon", config, is_subtemplate=False) + assert dirty_files(tmp_path) == ([], []) + + +def test_dirty_files_reports_modified_and_untracked(tmp_path): + config = PlonecliConfig() + (tmp_path / "a.py").write_text("a\n") + commit_template_changes(tmp_path, "backend_addon", config, is_subtemplate=False) + + (tmp_path / "a.py").write_text("changed\n") + (tmp_path / "new.py").write_text("new\n") + + modified, untracked = dirty_files(tmp_path) + assert "a.py" in modified + assert "new.py" in untracked diff --git a/tests/test_plonecli.py b/tests/test_plonecli.py index ac4dd13..b69c46b 100644 --- a/tests/test_plonecli.py +++ b/tests/test_plonecli.py @@ -1,5 +1,6 @@ """Tests for plonecli CLI commands.""" +import subprocess from unittest.mock import MagicMock, patch import pytest @@ -335,6 +336,160 @@ def test_create_non_interactive( assert kwargs["data"] == {"description": "Demo"} +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.ensure_templates_cloned") +def test_create_commits_by_default( + mock_ensure, mock_run_create, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + + result = runner.invoke(cli, ["create", "backend_addon", "my.addon"]) + + assert result.exit_code == 0 + assert mock_run_create.call_args.kwargs["git_commit"] is True + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.ensure_templates_cloned") +def test_create_no_git_flag( + mock_ensure, mock_run_create, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + + result = runner.invoke(cli, ["create", "backend_addon", "my.addon", "--no-git"]) + + assert result.exit_code == 0 + assert mock_run_create.call_args.kwargs["git_commit"] is False + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.ensure_templates_cloned") +def test_create_respects_auto_commit_config( + mock_ensure, mock_run_create, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=False) + + result = runner.invoke(cli, ["create", "backend_addon", "my.addon"]) + + assert result.exit_code == 0 + assert mock_run_create.call_args.kwargs["git_commit"] is False + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_no_git_flag( + mock_ensure, mock_run_add, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = MagicMock( + root_folder=tmp_path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + result = runner.invoke(cli, ["add", "behavior", "--no-git"]) + + assert result.exit_code == 0 + assert mock_run_add.call_args.kwargs["git_commit"] is False + + +def _dirty_repo(path): + """Init a git repo at ``path`` with an uncommitted (untracked) file.""" + subprocess.run(["git", "init"], cwd=str(path), check=True, capture_output=True) + (path / "wip.txt").write_text("work in progress\n") + + +def _project_at(path): + return MagicMock( + root_folder=path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + +@patch("plonecli.cli._is_interactive", return_value=True) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_aborts_on_dirty_repo( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = _project_at(tmp_path) + + result = runner.invoke(cli, ["add", "behavior"], input="n\n") + + assert result.exit_code == 0 + assert "uncommitted changes" in result.output + assert "Aborted" in result.output + mock_run_add.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=True) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_proceeds_when_confirmed_on_dirty_repo( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = _project_at(tmp_path) + + result = runner.invoke(cli, ["add", "behavior"], input="y\n") + + assert result.exit_code == 0 + mock_run_add.assert_called_once() + + +@patch("plonecli.cli._is_interactive", return_value=True) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_dirty_repo_bypassed_by_defaults( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = _project_at(tmp_path) + + result = runner.invoke( + cli, ["add", "upgrade_step", "--defaults", "-d", "upgrade_step_title=X"] + ) + + assert result.exit_code == 0 + # Warning still shown, but no prompt and the run proceeds. + assert "uncommitted changes" in result.output + mock_run_add.assert_called_once() + + @patch("plonecli.cli.find_project_root", return_value=None) @patch("plonecli.cli.load_config") def test_add_outside_project(mock_config, mock_project, runner, tmp_path): From 0f2790579e33ad407b8acf3c9d39614b73144593 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 23 May 2026 13:24:11 -0700 Subject: [PATCH 2/2] Auto-clone templates on first use; store templates path home-relative create/add/-l now clone copier-templates before reading the registry, so a freshly installed plonecli works without a manual 'plonecli update'. save_config writes local_path as ~/... when under HOME, keeping config.toml portable across machines and containers with a different $HOME. --- CHANGES.md | 12 ++++++++++ plonecli/cli.py | 19 ++++++++++++++++ plonecli/config.py | 19 +++++++++++++++- tests/test_config.py | 51 ++++++++++++++++++++++++++++++++++++++++++ tests/test_plonecli.py | 33 ++++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 706c8a4..6d7f12e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,18 @@ ## 7.0.0b8 (unreleased) +- Clone copier-templates automatically on first use of `create`, `add` and + `-l`. A freshly installed plonecli now works without a manual `plonecli + update` first. + [MrTango] + + +- Store the templates `local_path` home-relative (`~/...`) in `config.toml` so + the same config works across machines and containers with a different + `$HOME`. Paths outside the home directory stay absolute. + [MrTango] + + - Auto-commit by default: `plonecli create` initialises a git repo and commits the generated package, and `plonecli add` commits each subtemplate run. Opt out per run with `--no-git` or globally via `auto_commit = false` in the diff --git a/plonecli/cli.py b/plonecli/cli.py index 10c2160..7c20543 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -26,6 +26,22 @@ def echo(msg, fg="green", reverse=False): click.echo(click.style(msg, fg=fg, reverse=reverse)) +def ensure_templates(config): + """Clone the copier-templates on first use. + + Template discovery, resolution and listing all read from the local clone, so + they must run *after* it exists. Calling this before using the registry means + a freshly installed plonecli works without a manual ``plonecli update``. + Idempotent: a no-op once the clone is present. + """ + from pathlib import Path + + templates_dir = Path(config.templates_dir) + if not (templates_dir / ".git").exists(): + echo("\nFetching copier-templates (first run)...", fg="green") + ensure_templates_cloned(config) + + def _is_interactive(): """Whether we can prompt the user (stdin is a terminal).""" return sys.stdin.isatty() @@ -160,6 +176,7 @@ def cli(context, list_templates, versions): } if list_templates: + ensure_templates(config) reg = TemplateRegistry(config, project) click.echo(reg.list_templates()) @@ -250,6 +267,7 @@ def format_help(self, ctx, formatter): def create(context, template, name, data, data_file, defaults, no_git): """Create a new Plone package""" config = context.obj["config"] + ensure_templates(config) reg = TemplateRegistry(config) resolved = reg.resolve_template_name(template) @@ -324,6 +342,7 @@ def add(context, template, data, data_file, defaults, no_git): raise NotInPackageError(context.command.name) config = context.obj["config"] + ensure_templates(config) reg = TemplateRegistry(config, project) resolved = reg.resolve_template_name(template) diff --git a/plonecli/config.py b/plonecli/config.py index 953765f..efef1a4 100644 --- a/plonecli/config.py +++ b/plonecli/config.py @@ -76,6 +76,23 @@ def load_config() -> PlonecliConfig: return config +def _portable_path(path_str: str) -> str: + """Collapse a leading home directory to ``~`` for portability. + + ``load_config`` expands ``~`` per-environment, so storing the home-relative + form keeps the config working across machines and containers with different + ``$HOME`` (e.g. host vs. devcontainer). Paths outside home are left absolute. + """ + path = Path(path_str) + home = Path.home() + if path == home: + return "~" + try: + return "~/" + str(path.relative_to(home)) + except ValueError: + return path_str + + def save_config(config: PlonecliConfig) -> None: """Save config to ~/.plonecli/config.toml.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -92,7 +109,7 @@ def save_config(config: PlonecliConfig) -> None: [templates] repo_url = "{config.repo_url}" branch = "{config.repo_branch}" -local_path = "{config.templates_dir}" +local_path = "{_portable_path(config.templates_dir)}" [git] auto_commit = {str(config.auto_commit).lower()} diff --git a/tests/test_config.py b/tests/test_config.py index 8a986de..1001254 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -86,6 +86,57 @@ def test_save_config(tmp_path, monkeypatch): assert 'plone_version = "6.0.13"' in content +def test_save_collapses_home_in_templates_path(tmp_path, monkeypatch): + """The default templates_dir is stored home-relative, not as an absolute path. + + Regression: an absolute ``/home//...`` baked into config.toml broke + when the same config was read under a different ``$HOME`` (host vs. + devcontainer). + """ + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + config_dir = home / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + save_config(PlonecliConfig(templates_dir=str(home / ".copier-templates" / "x"))) + + content = config_file.read_text() + assert 'local_path = "~/.copier-templates/x"' in content + assert str(home) not in content + + +def test_save_keeps_paths_outside_home_absolute(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + config_dir = home / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + save_config(PlonecliConfig(templates_dir="/opt/templates")) + + assert 'local_path = "/opt/templates"' in config_file.read_text() + + +def test_templates_path_reloads_under_different_home(tmp_path, monkeypatch): + """A config saved under one $HOME expands correctly under another.""" + config_dir = tmp_path / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + home_a = tmp_path / "home_a" + monkeypatch.setenv("HOME", str(home_a)) + save_config(PlonecliConfig(templates_dir=str(home_a / ".copier-templates" / "x"))) + + home_b = tmp_path / "home_b" + monkeypatch.setenv("HOME", str(home_b)) + loaded = load_config() + assert loaded.templates_dir == str(home_b / ".copier-templates" / "x") + + def test_save_and_reload(tmp_path, monkeypatch): config_dir = tmp_path / ".plonecli" config_file = config_dir / "config.toml" diff --git a/tests/test_plonecli.py b/tests/test_plonecli.py index b69c46b..8092b9d 100644 --- a/tests/test_plonecli.py +++ b/tests/test_plonecli.py @@ -51,7 +51,8 @@ def _make_template(tmp_path, name, plonecli_meta): @patch("plonecli.cli.find_project_root", return_value=None) @patch("plonecli.cli.load_config") def test_cli_list_templates(mock_config, mock_project, runner, tmp_path): - # Set up mock templates dir + # Set up mock templates dir as an existing clone (has .git) + (tmp_path / ".git").mkdir() _make_template(tmp_path, "backend_addon", {"type": "main", "aliases": ["addon"]}) _make_template(tmp_path, "zope-setup", {"type": "main"}) _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) @@ -156,6 +157,36 @@ def test_create_unknown_template(mock_config, mock_project, runner, tmp_path): assert result.exit_code != 0 +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.ensure_templates_cloned") +def test_create_clones_templates_on_first_run( + mock_ensure, mock_run_create, mock_config, mock_project, runner, tmp_path +): + """A fresh install must clone before resolving the template, not fail. + + Regression: template resolution reads the local clone, so it has to run + *after* the clone. Without the auto-clone, ``create`` raised NoSuchValue + on an empty templates dir and forced a manual ``plonecli update``. + """ + templates_dir = tmp_path / "clone" + mock_config.return_value = MagicMock(templates_dir=str(templates_dir)) + + # Simulate the first-run clone populating the templates dir. + def fake_clone(config): + templates_dir.mkdir(parents=True, exist_ok=True) + _make_template(templates_dir, "backend_addon", {"type": "main"}) + + mock_ensure.side_effect = fake_clone + + result = runner.invoke(cli, ["create", "backend_addon", "my.addon"]) + + assert result.exit_code == 0, result.output + mock_ensure.assert_called_once() + mock_run_create.assert_called_once() + + @patch("plonecli.cli.find_project_root") @patch("plonecli.cli.load_config") @patch("plonecli.cli.run_add")