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
22 changes: 22 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
## 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
`[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.
- Add `reference/fields.md` to the bundled plonecli skill: a per-field question
flow (name, type, required, default) plus a full Plone field/widget/autoform
catalogue (sourced from plone-vs-snippets), so the agent writes correct schema
Expand Down
100 changes: 95 additions & 5 deletions plonecli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,52 @@ 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()


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.

Expand Down Expand Up @@ -130,6 +176,7 @@ def cli(context, list_templates, versions):
}

if list_templates:
ensure_templates(config)
reg = TemplateRegistry(config, project)
click.echo(reg.list_templates())

Expand Down Expand Up @@ -210,10 +257,17 @@ 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"]
ensure_templates(config)
reg = TemplateRegistry(config)

resolved = reg.resolve_template_name(template)
Expand All @@ -224,16 +278,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


Expand All @@ -259,14 +328,21 @@ 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:
raise NotInPackageError(context.command.name)

config = context.obj["config"]
ensure_templates(config)
reg = TemplateRegistry(config, project)

resolved = reg.resolve_template_name(template)
Expand All @@ -277,9 +353,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()
Expand All @@ -294,6 +380,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)
Expand Down
25 changes: 24 additions & 1 deletion plonecli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -73,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)
Expand All @@ -89,7 +109,10 @@ 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()}
"""
CONFIG_FILE.write_text(content)

Expand Down
Loading
Loading