Skip to content

Commit abdb416

Browse files
authored
Merge pull request #104 from plone/MrTango/auto-clone-templates
Auto-clone templates on first use; portable git auto-commit
2 parents 0fb064f + ce1c3bd commit abdb416

11 files changed

Lines changed: 699 additions & 12 deletions

File tree

CHANGES.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,28 @@
33
## 7.0.0b8 (unreleased)
44

55

6+
- Clone copier-templates automatically on first use of `create`, `add` and
7+
`-l`. A freshly installed plonecli now works without a manual `plonecli
8+
update` first.
9+
[MrTango]
10+
11+
12+
- Store the templates `local_path` home-relative (`~/...`) in `config.toml` so
13+
the same config works across machines and containers with a different
14+
`$HOME`. Paths outside the home directory stay absolute.
15+
[MrTango]
16+
17+
18+
- Auto-commit by default: `plonecli create` initialises a git repo and commits
19+
the generated package, and `plonecli add` commits each subtemplate run. Opt
20+
out per run with `--no-git` or globally via `auto_commit = false` in the
21+
`[git]` section of `~/.plonecli/config.toml`.
22+
[MrTango]
23+
24+
25+
- Warn and prompt before running `create`/`add`/`setup` on a git repository
26+
with uncommitted changes; the prompt defaults to cancel. Non-interactive runs
27+
(`--defaults` or no tty) print the warning but proceed.
628
- Add `reference/fields.md` to the bundled plonecli skill: a per-field question
729
flow (name, type, required, default) plus a full Plone field/widget/autoform
830
catalogue (sourced from plone-vs-snippets), so the agent writes correct schema

plonecli/cli.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,52 @@ def echo(msg, fg="green", reverse=False):
2626
click.echo(click.style(msg, fg=fg, reverse=reverse))
2727

2828

29+
def ensure_templates(config):
30+
"""Clone the copier-templates on first use.
31+
32+
Template discovery, resolution and listing all read from the local clone, so
33+
they must run *after* it exists. Calling this before using the registry means
34+
a freshly installed plonecli works without a manual ``plonecli update``.
35+
Idempotent: a no-op once the clone is present.
36+
"""
37+
from pathlib import Path
38+
39+
templates_dir = Path(config.templates_dir)
40+
if not (templates_dir / ".git").exists():
41+
echo("\nFetching copier-templates (first run)...", fg="green")
42+
ensure_templates_cloned(config)
43+
44+
45+
def _is_interactive():
46+
"""Whether we can prompt the user (stdin is a terminal)."""
47+
return sys.stdin.isatty()
48+
49+
50+
def confirm_clean_git(target_dir, defaults: bool) -> bool:
51+
"""Warn and ask to continue if the target repo has uncommitted changes.
52+
53+
Returns True to proceed, False if the user cancels. The prompt defaults to
54+
*cancel* so an accidental Enter is safe. In non-interactive mode (``--defaults``
55+
or no tty) the warning is printed but the run proceeds without prompting.
56+
"""
57+
from plonecli.git import dirty_files
58+
59+
modified, untracked = dirty_files(target_dir)
60+
if not modified and not untracked:
61+
return True
62+
63+
echo("\nWARNING: the git repository has uncommitted changes:", fg="yellow")
64+
for f in modified:
65+
echo(f" modified: {f}", fg="yellow")
66+
for f in untracked:
67+
echo(f" untracked: {f}", fg="yellow")
68+
echo("", fg="yellow")
69+
70+
if defaults or not _is_interactive():
71+
return True
72+
return click.confirm("Continue anyway?", default=False)
73+
74+
2975
def _parse_data(pairs):
3076
"""Parse ``KEY=VALUE`` strings into a dict of copier answers.
3177
@@ -130,6 +176,7 @@ def cli(context, list_templates, versions):
130176
}
131177

132178
if list_templates:
179+
ensure_templates(config)
133180
reg = TemplateRegistry(config, project)
134181
click.echo(reg.list_templates())
135182

@@ -210,10 +257,17 @@ def format_help(self, ctx, formatter):
210257
help="Use template defaults for unanswered questions instead of prompting "
211258
"(non-interactive).",
212259
)
260+
@click.option(
261+
"--no-git",
262+
"no_git",
263+
is_flag=True,
264+
help="Do not initialise git or auto-commit the generated package.",
265+
)
213266
@click.pass_context
214-
def create(context, template, name, data, data_file, defaults):
267+
def create(context, template, name, data, data_file, defaults, no_git):
215268
"""Create a new Plone package"""
216269
config = context.obj["config"]
270+
ensure_templates(config)
217271
reg = TemplateRegistry(config)
218272

219273
resolved = reg.resolve_template_name(template)
@@ -224,16 +278,31 @@ def create(context, template, name, data, data_file, defaults):
224278
possibilities=reg.get_main_templates(),
225279
)
226280

281+
if not confirm_clean_git(name, defaults):
282+
echo("Aborted.", fg="yellow")
283+
return
284+
285+
git_commit = config.auto_commit and not no_git
227286
answers = _collect_data(data_file, data)
228287
steps = reg.get_composite_steps(resolved)
229288
if steps:
230289
echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True)
231290
for step in steps:
232291
echo(f"\n Applying template: {step}", fg="green")
233-
run_create(step, name, config, data=answers, defaults=defaults)
292+
committed = run_create(
293+
step, name, config, data=answers, defaults=defaults,
294+
git_commit=git_commit,
295+
)
296+
if committed:
297+
echo(f" Committed: {committed}", fg="green")
234298
else:
235299
echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True)
236-
run_create(resolved, name, config, data=answers, defaults=defaults)
300+
committed = run_create(
301+
resolved, name, config, data=answers, defaults=defaults,
302+
git_commit=git_commit,
303+
)
304+
if committed:
305+
echo(f" Committed: {committed}", fg="green")
237306
context.obj["target_dir"] = name
238307

239308

@@ -259,14 +328,21 @@ def create(context, template, name, data, data_file, defaults):
259328
help="Use template defaults for unanswered questions instead of prompting "
260329
"(non-interactive).",
261330
)
331+
@click.option(
332+
"--no-git",
333+
"no_git",
334+
is_flag=True,
335+
help="Do not auto-commit the changes made by this subtemplate.",
336+
)
262337
@click.pass_context
263-
def add(context, template, data, data_file, defaults):
338+
def add(context, template, data, data_file, defaults, no_git):
264339
"""Add features to your existing Plone package"""
265340
project = context.obj.get("project")
266341
if project is None:
267342
raise NotInPackageError(context.command.name)
268343

269344
config = context.obj["config"]
345+
ensure_templates(config)
270346
reg = TemplateRegistry(config, project)
271347

272348
resolved = reg.resolve_template_name(template)
@@ -277,9 +353,19 @@ def add(context, template, data, data_file, defaults):
277353
possibilities=reg.get_subtemplates(),
278354
)
279355

356+
if not confirm_clean_git(project.root_folder, defaults):
357+
echo("Aborted.", fg="yellow")
358+
return
359+
360+
git_commit = config.auto_commit and not no_git
280361
answers = _collect_data(data_file, data)
281362
echo(f"\nAdding {resolved} to {project.root_folder.name}", fg="green", reverse=True)
282-
run_add(resolved, project, config, data=answers, defaults=defaults)
363+
committed = run_add(
364+
resolved, project, config, data=answers, defaults=defaults,
365+
git_commit=git_commit,
366+
)
367+
if committed:
368+
echo(f" Committed: {committed}", fg="green")
283369

284370

285371
@cli.command()
@@ -294,6 +380,10 @@ def setup(context):
294380
"The 'setup' command can only be run inside a backend_addon project."
295381
)
296382

383+
if not confirm_clean_git(project.root_folder, defaults=False):
384+
echo("Aborted.", fg="yellow")
385+
return
386+
297387
config = context.obj["config"]
298388
echo("\nRunning zope-setup...", fg="green", reverse=True)
299389
run_create("zope-setup", str(project.root_folder), config)

plonecli/config.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class PlonecliConfig:
3030
repo_url: str = DEFAULT_REPO_URL
3131
repo_branch: str = DEFAULT_BRANCH
3232
templates_dir: str = str(TEMPLATES_DIR)
33+
auto_commit: bool = True
3334

3435

3536
def load_config() -> PlonecliConfig:
@@ -50,6 +51,7 @@ def load_config() -> PlonecliConfig:
5051
author = data.get("author", {})
5152
defaults = data.get("defaults", {})
5253
templates = data.get("templates", {})
54+
git = data.get("git", {})
5355

5456
config.author_name = author.get("name", config.author_name)
5557
config.author_email = author.get("email", config.author_email)
@@ -58,6 +60,7 @@ def load_config() -> PlonecliConfig:
5860
config.repo_url = templates.get("repo_url", config.repo_url)
5961
config.repo_branch = templates.get("branch", config.repo_branch)
6062
config.templates_dir = templates.get("local_path", config.templates_dir)
63+
config.auto_commit = git.get("auto_commit", config.auto_commit)
6164

6265
# Environment variables override config file
6366
if os.environ.get(ENV_REPO_URL):
@@ -73,6 +76,23 @@ def load_config() -> PlonecliConfig:
7376
return config
7477

7578

79+
def _portable_path(path_str: str) -> str:
80+
"""Collapse a leading home directory to ``~`` for portability.
81+
82+
``load_config`` expands ``~`` per-environment, so storing the home-relative
83+
form keeps the config working across machines and containers with different
84+
``$HOME`` (e.g. host vs. devcontainer). Paths outside home are left absolute.
85+
"""
86+
path = Path(path_str)
87+
home = Path.home()
88+
if path == home:
89+
return "~"
90+
try:
91+
return "~/" + str(path.relative_to(home))
92+
except ValueError:
93+
return path_str
94+
95+
7696
def save_config(config: PlonecliConfig) -> None:
7797
"""Save config to ~/.plonecli/config.toml."""
7898
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -89,7 +109,10 @@ def save_config(config: PlonecliConfig) -> None:
89109
[templates]
90110
repo_url = "{config.repo_url}"
91111
branch = "{config.repo_branch}"
92-
local_path = "{config.templates_dir}"
112+
local_path = "{_portable_path(config.templates_dir)}"
113+
114+
[git]
115+
auto_commit = {str(config.auto_commit).lower()}
93116
"""
94117
CONFIG_FILE.write_text(content)
95118

0 commit comments

Comments
 (0)