Repository: copier-org/copier (python)

Issue:
Ability to define safe/unsafe modes and force to ignore _tasks
### Actual Situation

If we define `_tasks`, the end user will need to use `--UNSAFE/--trust` to generate the project from the template.
In some situation, the `_tasks` commands are just convenience.

More precisely, in my case:
```yaml
_tasks:
    - "git init -b main && git add -A && git commit -m 'Initial commit'"
    - "pre-commit install"
```

### Desired Situation

I would like the end-user to be able to use the template without running any unsafe feature.

### Proposed solution

The end-user could be able to use a flag (e.g. `--no-script`) to not run any unsafe feature but still create the project.

From the template's creator side, an additional feature would be to mark the `_tasks` as `required/optional` to control if the end-user is allowed to copy the project without the tasks.

Pull request:
feat(tasks): allow skipping tasks with the  flag
This PR add a `-T/--skip-tasks` flag allowing to skip template tasks execution.

Some known use cases I have:
- while testing my template, I want to be able to perform multiple rendering with the extensions actives but without running tasks (which may be slow or depending on connection...)
- when applying a template with some bootstrap tasks on an existing project, I want to be able to skip the bootstrap tasks because I know they will fail
- when applying a third party template that I trust, I want to only skip tasks for some reason (known to break, not compatible with the environment...)

Should fix #1415


Patch:
diff --git a/copier/cli.py b/copier/cli.py
index 1e52030..3614304 100644
--- a/copier/cli.py
+++ b/copier/cli.py
@@ -157,6 +157,11 @@ class _Subcommand(cli.Application):  # type: ignore[misc]
             "Allow templates with unsafe features (Jinja extensions, migrations, tasks)"
         ),
     )
+    skip_tasks = cli.Flag(
+        ["-T", "--skip-tasks"],
+        default=False,
+        help="Skip template tasks execution",
+    )
 
     @cli.switch(  # type: ignore[misc]
         ["-d", "--data"],
diff --git a/copier/main.py b/copier/main.py
index 29c671d..5ab475f 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -162,6 +162,9 @@ class Worker:
 
         skip_answered:
             When `True`, skip questions that have already been answered.
+
+        skip_tasks:
+            When `True`, skip template tasks execution.
     """
 
     src_path: str | None = None
@@ -182,6 +185,7 @@ class Worker:
     context_lines: PositiveInt = 3
     unsafe: bool = False
     skip_answered: bool = False
+    skip_tasks: bool = False
 
     answers: AnswersMap = field(default_factory=AnswersMap, init=False)
     _cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
@@ -766,7 +770,8 @@ class Worker:
             if not self.quiet:
                 # TODO Unify printing tools
                 print("")  # padding space
-            self._execute_tasks(self.template.tasks)
+            if not self.skip_tasks:
+                self._execute_tasks(self.template.tasks)
         except Exception:
             if not was_existing and self.cleanup_on_error:
                 rmtree(self.subproject.local_abspath)
diff --git a/docs/configuring.md b/docs/configuring.md
index 3ac8b3d..91bcbd7 100644
--- a/docs/configuring.md
+++ b/docs/configuring.md
@@ -1303,6 +1303,22 @@ exist.
     {{999999999999999999999999999999999|ans_random|hash('sha512')}}
     ```
 
+### `skip_tasks`
+
+-   Format: `bool`
+-   CLI Flags: `-T`, `--skip-tasks`
+-   Default value: `False`
+
+Skip template [tasks][tasks] execution, if set to `True`.
+
+!!! note
+
+    It only skips [tasks][tasks], not [migration tasks][migrations].
+
+!!! question "Does it imply `--trust`?"
+
+    This flag does not imply [`--trust`][unsafe], and will do nothing if not used with.
+
 ### `subdirectory`
 
 -   Format: `str`
diff --git a/poetry.lock b/poetry.lock
index a26cf28..0de8ebf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -617,13 +617,13 @@ mkdocs = ">=1.1"
 
 [[package]]
 name = "mkdocs-material"
-version = "9.5.16"
+version = "9.5.17"
 description = "Documentation that simply works"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_material-9.5.16-py3-none-any.whl", hash = "sha256:32fce3cd8ecbd5dca6e5887cc0cf5bc78707a36f7d0f6f1bbbe9edaf428b8055"},
-    {file = "mkdocs_material-9.5.16.tar.gz", hash = "sha256:8b89b639592660f24657bb058de4aff0060cd0383148f8f51711201730f17503"},
+    {file = "mkdocs_material-9.5.17-py3-none-any.whl", hash = "sha256:14a2a60119a785e70e765dd033e6211367aca9fc70230e577c1cf6a326949571"},
+    {file = "mkdocs_material-9.5.17.tar.gz", hash = "sha256:06ae1275a72db1989cf6209de9e9ecdfbcfdbc24c58353877b2bb927dbe413e4"},
 ]
 
 [package.dependencies]
@@ -657,13 +657,13 @@ files = [
 
 [[package]]
 name = "mkdocstrings"
-version = "0.24.1"
+version = "0.24.2"
 description = "Automatic documentation from sources, for MkDocs."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocstrings-0.24.1-py3-none-any.whl", hash = "sha256:b4206f9a2ca8a648e222d5a0ca1d36ba7dee53c88732818de183b536f9042b5d"},
-    {file = "mkdocstrings-0.24.1.tar.gz", hash = "sha256:cc83f9a1c8724fc1be3c2fa071dd73d91ce902ef6a79710249ec8d0ee1064401"},
+    {file = "mkdocstrings-0.24.2-py3-none-any.whl", hash = "sha256:61440b77542170238099a7d87882c3417897771950e3aafe6e22abff3d1c51fb"},
+    {file = "mkdocstrings-0.24.2.tar.gz", hash = "sha256:b91b9cdd9490ef2e8957000bff1d34a4b308b9cd57b10f26169f085def4c6a92"},
 ]
 
 [package.dependencies]


Test patch:
diff --git a/tests/demo_migrations/tasks.py b/tests/demo_migrations/tasks.py
index c64df92..a046b97 100755
--- a/tests/demo_migrations/tasks.py
+++ b/tests/demo_migrations/tasks.py
@@ -3,10 +3,8 @@ import os
 import os.path
 import sys
 from contextlib import suppress
-from subprocess import check_call
 
 with open("created-with-tasks.txt", "a", newline="\n") as cwt:
     cwt.write(" ".join([os.environ["STAGE"]] + sys.argv[1:]) + "\n")
-    check_call(["git", "init"])
     with suppress(FileNotFoundError):
         os.unlink("delete-in-tasks.txt")
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index d3c6ebd..a134ec2 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -26,7 +26,8 @@ SRC = Path(f"{PROJECT_TEMPLATE}_migrations").absolute()
     reason="Windows ignores shebang?",
     strict=True,
 )
-def test_migrations_and_tasks(tmp_path: Path) -> None:
+@pytest.mark.parametrize("skip_tasks", [True, False])
+def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None:
     """Check migrations and tasks are run properly."""
     # Convert demo_migrations in a git repository with 2 versions
     src, dst = tmp_path / "src", tmp_path / "dst"
@@ -41,10 +42,20 @@ def test_migrations_and_tasks(tmp_path: Path) -> None:
         git("commit", "--allow-empty", "-m2")
         git("tag", "v2.0")
     # Copy it in v1
-    run_copy(src_path=str(src), dst_path=dst, vcs_ref="v1.0.0", unsafe=True)
+    run_copy(
+        src_path=str(src),
+        dst_path=dst,
+        vcs_ref="v1.0.0",
+        unsafe=True,
+        skip_tasks=skip_tasks,
+    )
     # Check copy was OK
-    assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n"
-    assert not (dst / "delete-in-tasks.txt").exists()
+    if skip_tasks:
+        assert not (dst / "created-with-tasks.txt").exists()
+        assert (dst / "delete-in-tasks.txt").exists()
+    else:
+        assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n"
+        assert not (dst / "delete-in-tasks.txt").exists()
     assert (dst / "delete-in-migration-v2.txt").is_file()
     assert not (dst / "migrations.py").exists()
     assert not (dst / "tasks.py").exists()
@@ -54,15 +65,22 @@ def test_migrations_and_tasks(tmp_path: Path) -> None:
     assert answers == {"_commit": "v1.0.0", "_src_path": str(src)}
     # Save changes in downstream repo
     with local.cwd(dst):
+        git("init")
         git("add", ".")
         git("config", "user.name", "Copier Test")
         git("config", "user.email", "test@copier")
         git("commit", "-m1")
     # Update it to v2
-    run_update(dst_path=dst, defaults=True, overwrite=True, unsafe=True)
+    run_update(
+        dst_path=dst, defaults=True, overwrite=True, unsafe=True, skip_tasks=skip_tasks
+    )
     # Check update was OK
-    assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" * 2
-    assert not (dst / "delete-in-tasks.txt").exists()
+    if skip_tasks:
+        assert not (dst / "created-with-tasks.txt").exists()
+        assert (dst / "delete-in-tasks.txt").exists()
+    else:
+        assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" * 2
+        assert not (dst / "delete-in-tasks.txt").exists()
     assert not (dst / "delete-in-migration-v2.txt").exists()
     assert not (dst / "migrations.py").exists()
     assert not (dst / "tasks.py").exists()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index afbc0e0..e60a75d 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -53,6 +53,23 @@ def test_copy_tasks(template_path: str, tmp_path: Path) -> None:
     assert (tmp_path / "pyfile").is_file()
 
 
+def test_copy_skip_tasks(template_path: str, tmp_path: Path) -> None:
+    copier.run_copy(
+        template_path,
+        tmp_path,
+        quiet=True,
+        defaults=True,
+        overwrite=True,
+        unsafe=True,
+        skip_tasks=True,
+    )
+    assert not (tmp_path / "hello").exists()
+    assert not (tmp_path / "hello").is_dir()
+    assert not (tmp_path / "hello" / "world").exists()
+    assert not (tmp_path / "bye").is_file()
+    assert not (tmp_path / "pyfile").is_file()
+
+
 def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None:
     src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
     build_file_tree(