Repository: copier-org/copier
Pull request #1782 (2024-09-21 05:47:56)

docs(git): tell how to pass HTTPS credentials
Fixes https://github.com/copier-org/copier/issues/466


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


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