Repository: copier-org/copier
Pull request #1781 (2024-09-20 15:40:20)

Document how to update when changing a multiselect base answer
Document how to update when changing a multiselect base answer
The documentation to update, changing only one answer  when the question is a multi-select base choice, is missing.

This is from https://github.com/copier-org/copier/issues/1474#issuecomment-2328522026, but it also indicates to create the `.yaml` file in another directory (to avoid error `Destination repository is dirty; cannot continue. Please commit or stash your local changes and retry.`).


Patch:
diff --git a/copier/main.py b/copier/main.py
index dc076fc..dc0c9c0 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -480,9 +480,6 @@ class Worker:
                 else:
                     if question.validate_answer(answer):
                         del result.last[var_name]
-            # Skip a question when the user already answered it.
-            if self.skip_answered and var_name in result.last:
-                continue
             # Skip a question when the skip condition is met.
             if not question.get_when():
                 # Omit its answer from the answers file.
@@ -502,6 +499,9 @@ class Worker:
                 # question again, but set answer as the user's answer instead.
                 result.user[var_name] = answer
                 continue
+            # Skip a question when the user already answered it.
+            if self.skip_answered and var_name in result.last:
+                continue
 
             # Display TUI and ask user interactively only without --defaults
             try:
diff --git a/copier/user_data.py b/copier/user_data.py
index ad68f16..6c481a5 100644
--- a/copier/user_data.py
+++ b/copier/user_data.py
@@ -387,7 +387,7 @@ class Question:
             result["multiline"] = self.get_multiline()
             if placeholder := self.get_placeholder():
                 result["placeholder"] = placeholder
-        if questionary_type in {"input", "checkbox"}:
+        if questionary_type in {"input", "checkbox", "password"}:
             result["validate"] = _validate
         result.update({"type": questionary_type})
         return result
diff --git a/docs/faq.md b/docs/faq.md
index 99e75d2..f1a41c8 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -192,3 +192,22 @@ $ copier copy -r HEAD ./src ./dst
 
 ... then you'll notice `new-file.txt` does exist. You passed a specific ref to copy, so
 Copier skips its autodetection and just goes for the `HEAD` you already chose.
+
+## How to pass credentials to Git?
+
+If you do something like this, and the template supports updates, you'll notice that the
+credentials will end up stored in [the answers file][file][the-copier-answersyml-file]:
+
+```shell
+copier copy https://myuser:example.com/repo.git .
+```
+
+To avoid that, the simplest fix is to clone using SSH with cryptographic key
+authentication. If you cannot do that, then check out these links for strategies on
+passing HTTPS credentials to Git:
+
+-   https://github.com/copier-org/copier/issues/466#issuecomment-2338160284
+-   https://stackoverflow.com/q/35942754
+-   https://git-scm.com/docs/gitcredentials
+-   https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#_credential_caching
+-   https://github.com/topics/git-credential-helper
diff --git a/docs/updating.md b/docs/updating.md
index 558de39..fccac83 100644
--- a/docs/updating.md
+++ b/docs/updating.md
@@ -129,6 +129,19 @@ to go through the whole questionnaire again:
 copier update --defaults --data updated_question="my new answer"
 ```
 
+You can achieve the same using a [data file][data_file]:
+
+```shell
+echo "updated_question: my new answer" > /tmp/data-file.yaml
+copier update --defaults --data-file /tmp/data-file.yaml
+```
+
+!!! note
+
+    Due to [issue #1474](https://github.com/copier-org/copier/issues/1474),
+    it is not yet possible to update a multiselect choice using ˋ--dataˋ.
+    Use ˋ--data-fileˋ instead for now.
+
 ## How the update works
 
 To understand how the updating process works, take a look at this diagram:
diff --git a/flake.lock b/flake.lock
index 31cc97c..c21e0c3 100644
--- a/flake.lock
+++ b/flake.lock
@@ -212,12 +212,12 @@
         "systems": "systems_2"
       },
       "locked": {
-        "lastModified": 1710146030,
-        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
-        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
-        "revCount": 92,
+        "lastModified": 1726560853,
+        "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+        "revCount": 101,
         "type": "tarball",
-        "url": "https://api.flakehub.com/f/pinned/numtide/flake-utils/0.1.92%2Brev-b1d9ab70662946ef0850d488da1c9019f3a9752a/018e2ca5-e5a2-7f80-9261-445a8cecd4d7/source.tar.gz"
+        "url": "https://api.flakehub.com/f/pinned/numtide/flake-utils/0.1.101%2Brev-c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a/0191ff0c-3a46-7ad4-b5e7-9f6806881886/source.tar.gz"
       },
       "original": {
         "type": "tarball",
@@ -517,12 +517,12 @@
     },
     "nixpkgs_4": {
       "locked": {
-        "lastModified": 1726062281,
-        "narHash": "sha256-PyFVySdGj3enKqm8RQuo4v1KLJLmNLOq2yYOHsI6e2Q=",
-        "rev": "e65aa8301ba4f0ab8cb98f944c14aa9da07394f8",
-        "revCount": 635067,
+        "lastModified": 1726838390,
+        "narHash": "sha256-NmcVhGElxDbmEWzgXsyAjlRhUus/nEqPC5So7BOJLUM=",
+        "rev": "944b2aea7f0a2d7c79f72468106bc5510cbf5101",
+        "revCount": 635367,
         "type": "tarball",
-        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2405.635067%2Brev-e65aa8301ba4f0ab8cb98f944c14aa9da07394f8/0191efda-e9e3-7b0d-9d73-69665fd6ce0f/source.tar.gz"
+        "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2405.635367%2Brev-944b2aea7f0a2d7c79f72468106bc5510cbf5101/019213de-4759-79c6-b638-0878c1fd0179/source.tar.gz"
       },
       "original": {
         "type": "tarball",
@@ -558,11 +558,11 @@
         "treefmt-nix": "treefmt-nix"
       },
       "locked": {
-        "lastModified": 1726275961,
-        "narHash": "sha256-QMdPMN+I9pwuE7/9d5Dane0VwGxtoTVNWIp3TMSIrgI=",
+        "lastModified": 1726715047,
+        "narHash": "sha256-OUpbkEbpkRh91WX8XYq5rugIHhzJUS84qwvCd+HBer8=",
         "owner": "nix-community",
         "repo": "poetry2nix",
-        "rev": "9a04664289506c27ddbf098d98ce520155af574c",
+        "rev": "1f4d4dcab3ee4b6a674e9d9ca9c8d125c7811634",
         "type": "github"
       },
       "original": {


Test patch:
diff --git a/tests/test_copy.py b/tests/test_copy.py
index edaca49..0717a61 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -499,6 +499,15 @@ def test_value_with_forward_slash(tmp_path_factory: pytest.TempPathFactory) -> N
             "abc",
             does_not_raise(),
         ),
+        (
+            {
+                "type": "str",
+                "secret": True,
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            "",
+            pytest.raises(ValueError),
+        ),
         ({"type": "bool"}, "true", does_not_raise()),
         ({"type": "bool"}, "false", does_not_raise()),
         (
@@ -849,6 +858,15 @@ def test_required_choice_question_without_data(
         ("str", "1.0", does_not_raise()),
         ("str", 1.0, does_not_raise()),
         ("str", None, pytest.raises(TypeError)),
+        (
+            {
+                "type": "str",
+                "secret": True,
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            "",
+            pytest.raises(ValueError),
+        ),
         ("int", 1, does_not_raise()),
         ("int", 1.0, does_not_raise()),
         ("int", "1", does_not_raise()),
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index 43a85f3..03d54b1 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -973,3 +973,28 @@ def test_multiselect_choices_validator(
     tui.expect_exact(pexpect.EOF)
     answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
     assert answers["question"] == ["one"]
+
+
+def test_secret_validator(
+    question_tree: QuestionTreeFixture, copier: CopierFixture
+) -> None:
+    """Secret question answer is validated."""
+    default = "s3cret"
+    src, dst = question_tree(
+        type="str",
+        default=default,
+        secret=True,
+        validator="[% if question|length < 3 %]too short[% endif %]",
+    )
+
+    tui = copier("copy", str(src), str(dst), timeout=10)
+    expect_prompt(tui, "question", "str")
+    # Delete default value to fail validation
+    for _ in range(len(default)):
+        tui.send(Keyboard.Backspace)
+    tui.sendline()
+    tui.expect_exact("too short")
+    # Enter default value again to pass validation
+    tui.sendline(default)
+    tui.expect_exact("******")
+    tui.expect_exact(pexpect.EOF)
diff --git a/tests/test_recopy.py b/tests/test_recopy.py
index 06257d9..80a6731 100644
--- a/tests/test_recopy.py
+++ b/tests/test_recopy.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 
 import pytest
+import yaml
 from plumbum import local
 
 from copier import run_copy, run_recopy
@@ -72,3 +73,28 @@ def test_recopy_works_without_replay(tpl: str, tmp_path: Path) -> None:
     # Recopy
     run_recopy(tmp_path, skip_answered=True, overwrite=True)
     assert (tmp_path / "name.txt").read_text() == "This is my name: Mario."
+
+
+def test_recopy_with_skip_answered_and_new_answer(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            src / "copier.yml": "boolean: false",
+            src / "{{ _copier_conf.answers_file }}.jinja": (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+        }
+    )
+    git_save(src)
+    # First copy
+    run_copy(str(src), dst, defaults=True, overwrite=True)
+    git_save(dst)
+    answers_file = dst / ".copier-answers.yml"
+    answers = yaml.safe_load(answers_file.read_text())
+    assert answers["boolean"] is False
+    # Recopy with different answer and `skip_answered=True`
+    run_recopy(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
+    answers = yaml.safe_load(answers_file.read_text())
+    assert answers["boolean"] is True
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 2e73a41..a556de0 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -1518,3 +1518,31 @@ def test_update_with_separate_git_directory(
 
     run_update(dst, overwrite=True)
     assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+
+
+def test_update_with_skip_answered_and_new_answer(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    with local.cwd(src):
+        build_file_tree(
+            {
+                "copier.yml": "boolean: false",
+                "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
+            }
+        )
+        git_init("v1")
+        git("tag", "v1")
+
+    run_copy(str(src), dst, defaults=True, overwrite=True)
+    answers_file = dst / ".copier-answers.yml"
+    answers = yaml.safe_load(answers_file.read_text())
+    assert answers["boolean"] is False
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    run_update(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
+    answers = yaml.safe_load(answers_file.read_text())
+    assert answers["boolean"] is True