Repository: copier-org/copier
Pull request #2136 (2025-05-08 14:24:11)

feat: add `--vcs-ref=:current:` feature to reference the current ref when updating
Sometimes, a user might only want to update the answers to their questions, but not the template version.
As far as I am aware, copier does not support that currently.

In this PR, I add a `--answers-only` flag to `copier update` which fills exactly this gap.

Note about tests: I am a bit overwhelmed by your testing setup, could you maybe please point me to the right places to add corresponding tests? Thanks in advance!

Cc @pavelzw

ytausch: Thanks a ton for your elaborate response, @sisp!

> I've checked a few popular CLI tools and it seems that it's more common to place an adverb like "only" in front, i.e. --only-answers in this case.

Fixed!

> About the short flag -u, what's the rationale behind "u"? 

There is none really. How about `-O` for only?

> copier update --vcs-ref CURRENT ..

I had a similar thought when implementing this, but decided against it because I felt `--only-answers` could be a pretty common use case where `--vcs-ref <CURRENT>` (or similar) might be too complicated for users who are not too familiar with the tool. But I don't have any strong opinion on this.

Resuing `vcs_ref` in the Worker seems fine for me. I'll have a look into testing in the next days!
ytausch: Everything should now be implemented!
pawamoy: @sisp

> Then, when the --only-answers flag is used, we can pass vcs_ref=VCSRef.CURRENT to the Worker class constructor and condition the shortcut in the Worker.template property on the self.vcs_ref value instead of self.update_answers_only. WDYT, @pawamoy?

Yep, sounds good. I do like better the shortcut approach with a dedicated flag, rather than reusing `--vcs-ref` :+1: 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6781150..db178de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ env:
   PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }}
   PYTHONIOENCODING: "UTF-8"
   # renovate: datasource=pypi depName=uv
-  UV_VERSION: "0.7.4"
+  UV_VERSION: "0.7.6"
 
 jobs:
   build:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3809e09..fb0120a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,7 +7,7 @@ on:
 
 env:
   # renovate: datasource=pypi depName=uv
-  UV_VERSION: "0.7.4"
+  UV_VERSION: "0.7.6"
 
 jobs:
   build:
diff --git a/copier/__init__.py b/copier/__init__.py
index eb0b2f0..190c9fc 100644
--- a/copier/__init__.py
+++ b/copier/__init__.py
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
 
 from . import _main
 from ._deprecation import deprecate_member_as_internal
+from ._types import VcsRef as VcsRef
 
 if TYPE_CHECKING:
     from ._main import *  # noqa: F403
diff --git a/copier/_cli.py b/copier/_cli.py
index d579ca2..6fff7f0 100644
--- a/copier/_cli.py
+++ b/copier/_cli.py
@@ -59,8 +59,8 @@ import yaml
 from plumbum import cli, colors
 
 from ._main import Worker
-from ._tools import copier_version
-from ._types import AnyByStrDict
+from ._tools import copier_version, try_enum
+from ._types import AnyByStrDict, VcsRef
 from .errors import UnsafeTemplateError, UserMessageError
 
 
@@ -134,7 +134,9 @@ class _Subcommand(cli.Application):  # type: ignore[misc]
             "Git reference to checkout in `template_src`. "
             "If you do not specify it, it will try to checkout the latest git tag, "
             "as sorted using the PEP 440 algorithm. If you want to checkout always "
-            "the latest version, use `--vcs-ref=HEAD`."
+            "the latest version, use `--vcs-ref=HEAD`. "
+            "Use the special value `:current:` to refer to the current reference "
+            "of the template if it already exists."
         ),
     )
     pretend = cli.Flag(["-n", "--pretend"], help="Run but do not make any changes")
@@ -220,7 +222,7 @@ class _Subcommand(cli.Application):  # type: ignore[misc]
             skip_if_exists=self.skip,
             quiet=self.quiet,
             src_path=src_path,
-            vcs_ref=self.vcs_ref,
+            vcs_ref=try_enum(VcsRef, self.vcs_ref),
             use_prereleases=self.prereleases,
             unsafe=self.unsafe,
             skip_tasks=self.skip_tasks,
diff --git a/copier/_main.py b/copier/_main.py
index f5fa1c1..2ae3acb 100644
--- a/copier/_main.py
+++ b/copier/_main.py
@@ -60,6 +60,7 @@ from ._types import (
     Phase,
     RelativePath,
     StrOrPath,
+    VcsRef,
 )
 from ._user_data import AnswersMap, Question, load_answersfile_data
 from ._vcs import get_git
@@ -219,7 +220,7 @@ class Worker:
     src_path: str | None = None
     dst_path: Path = Path()
     answers_file: RelativePath | None = None
-    vcs_ref: str | None = None
+    vcs_ref: str | VcsRef | None = None
     data: AnyByStrDict = field(default_factory=dict)
     settings: Settings = field(default_factory=Settings.from_file)
     exclude: Sequence[str] = ()
@@ -398,7 +399,7 @@ class Worker:
                 "src_path": lambda: self.template.local_abspath,
                 "dst_path": lambda: self.dst_path,
                 "answers_file": lambda: self.answers_relpath,
-                "vcs_ref": lambda: self.vcs_ref,
+                "vcs_ref": lambda: self.resolved_vcs_ref,
                 "vcs_ref_hash": lambda: self.template.commit_hash,
                 "data": lambda: self.data,
                 "settings": lambda: self.settings,
@@ -555,11 +556,9 @@ class Worker:
             if var_name in self.answers.last:
                 try:
                     answer = question.parse_answer(self.answers.last[var_name])
+                    question.validate_answer(answer)
                 except Exception:
                     del self.answers.last[var_name]
-                else:
-                    if question.validate_answer(answer):
-                        del self.answers.last[var_name]
             # Skip a question when the skip condition is met.
             if not question.get_when():
                 # Omit its answer from the answers file.
@@ -573,14 +572,10 @@ class Worker:
                 if question.default is MISSING:
                     continue
             if var_name in self.answers.init:
-                # Try to parse the answer value.
+                # Try to parse and validate (if the question has a validator)
+                # the answer value.
                 answer = question.parse_answer(self.answers.init[var_name])
-                # Try to validate the answer value if the question has a
-                # validator.
-                if err_msg := question.validate_answer(answer):
-                    raise ValueError(
-                        f"Validation error for question '{var_name}': {err_msg}"
-                    )
+                question.validate_answer(answer)
                 # At this point, the answer value is valid. Do not ask the
                 # question again, but set answer as the user's answer instead.
                 self.answers.user[var_name] = answer
@@ -959,6 +954,19 @@ class Worker:
         except TypeError:
             return value
 
+    @cached_property
+    def resolved_vcs_ref(self) -> str | None:
+        """Get the resolved VCS reference to use.
+
+        This is either `vcs_ref` or the subproject template ref
+        if `vcs_ref` is `VcsRef.CURRENT`.
+        """
+        if self.vcs_ref is VcsRef.CURRENT:
+            if self.subproject.template is None:
+                raise TypeError("Template not found")
+            return self.subproject.template.ref
+        return self.vcs_ref
+
     @cached_property
     def subproject(self) -> Subproject:
         """Get related subproject."""
@@ -971,15 +979,13 @@ class Worker:
 
     @cached_property
     def template(self) -> Template:
-        """Get related template."""
         url = self.src_path
         if not url:
             if self.subproject.template is None:
                 raise TypeError("Template not found")
             url = str(self.subproject.template.url)
-        result = Template(
-            url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases
-        )
+        ref = self.resolved_vcs_ref
+        result = Template(url=url, ref=ref, use_prereleases=self.use_prereleases)
         self._cleanup_hooks.append(result._cleanup)
         return result
 
@@ -1049,6 +1055,15 @@ class Worker:
         with replace(self, src_path=self.subproject.template.url) as new_worker:
             new_worker.run_copy()
 
+    def _print_template_update_info(self, subproject_template: Template) -> None:
+        # TODO Unify printing tools
+        if not self.quiet:
+            if subproject_template.version == self.template.version:
+                message = f"Keeping template version {self.template.version}"
+            else:
+                message = f"Updating to template version {self.template.version}"
+            print(message, file=sys.stderr)
+
     @as_operation("update")
     def run_update(self) -> None:
         """Update a subproject that was already generated.
@@ -1092,11 +1107,7 @@ class Worker:
             # asking for confirmation
             raise UserMessageError("Enable overwrite to update a subproject.")
         self._print_message(self.template.message_before_update)
-        if not self.quiet:
-            # TODO Unify printing tools
-            print(
-                f"Updating to template version {self.template.version}", file=sys.stderr
-            )
+        self._print_template_update_info(self.subproject.template)
         with suppress(AttributeError):
             # We might have switched operation context, ensure the cached property
             # is regenerated to re-render templates.
@@ -1208,6 +1219,7 @@ class Worker:
                 quiet=True,
                 src_path=self.subproject.template.url,  # type: ignore[union-attr]
                 exclude=exclude_plus_removed,
+                vcs_ref=self.resolved_vcs_ref,
             ) as new_worker:
                 new_worker.run_copy()
             with local.cwd(new_copy):
diff --git a/copier/_tools.py b/copier/_tools.py
index 0a6872f..1bd4f96 100644
--- a/copier/_tools.py
+++ b/copier/_tools.py
@@ -15,7 +15,7 @@ from enum import Enum
 from importlib.metadata import version
 from pathlib import Path
 from types import TracebackType
-from typing import Any, Callable, Literal, TextIO, cast
+from typing import Any, Callable, Literal, TextIO, TypeVar, cast
 
 import colorama
 from packaging.version import Version
@@ -265,3 +265,18 @@ def scantree(path: str, follow_symlinks: bool) -> Iterator[os.DirEntry[str]]:
         yield entry
         if entry.is_dir(follow_symlinks=follow_symlinks):
             yield from scantree(entry.path, follow_symlinks)
+
+
+_T = TypeVar("_T")
+_E = TypeVar("_E", bound=Enum)
+
+
+def try_enum(enum_type: type[_E], value: _T) -> _E | _T:
+    """Try to convert a value into an enum.
+
+    If the value is not a valid enum member, return the original value.
+    """
+    try:
+        return enum_type(value)
+    except ValueError:
+        return value
diff --git a/copier/_types.py b/copier/_types.py
index 795defc..f78fee5 100644
--- a/copier/_types.py
+++ b/copier/_types.py
@@ -125,3 +125,10 @@ class Phase(str, Enum):
 
 
 _phase: ContextVar[Phase] = ContextVar("phase", default=Phase.UNDEFINED)
+
+
+class VcsRef(Enum):
+    CURRENT = ":current:"
+    """A special value to indicate that the current ref of the existing
+    template should be used.
+    """
diff --git a/copier/_user_data.py b/copier/_user_data.py
index 5c2e66c..2d190d6 100644
--- a/copier/_user_data.py
+++ b/copier/_user_data.py
@@ -6,6 +6,7 @@ import json
 import warnings
 from collections import ChainMap
 from collections.abc import Mapping, Sequence
+from copy import deepcopy
 from dataclasses import field
 from datetime import datetime
 from functools import cached_property
@@ -275,7 +276,13 @@ class Question:
                     result = self.render_value(
                         self.settings.defaults.get(self.var_name, self.default)
                     )
-        result = self.cast_answer(result)
+        result = self.parse_answer(result)
+        # Computed values (i.e., `when: false`) are intentionally not validated
+        # at the moment.
+        # https://github.com/copier-org/copier/issues/1779#issuecomment-2365006990
+        # https://github.com/copier-org/copier/pull/1785
+        if self.get_when():
+            self.validate_answer(result)
         return result
 
     def get_default_rendered(self) -> bool | str | Choice | None | MissingType:
@@ -319,7 +326,6 @@ class Question:
         """Obtain choices rendered and properly formatted."""
         result = []
         choices = self.choices
-        default = self.get_default()
         if isinstance(choices, str):
             choices = parse_yaml_string(self.render_value(self.choices))
         if isinstance(choices, dict):
@@ -344,15 +350,7 @@ class Question:
 
                 disabled = self.render_value(value.get("validator", ""))
                 value = value["value"]
-            # The value can be templated
-            value = self.render_value(value)
-            checked = (
-                self.multiselect
-                and isinstance(default, list)
-                and self.cast_answer(value) in default
-                or None
-            )
-            c = Choice(name, value, disabled=disabled, checked=checked)
+            c = Choice(name, self.render_value(value), disabled=disabled)
             # Try to cast the value according to the question's type to raise
             # an error in case the value is incompatible.
             self.cast_answer(c.value)
@@ -382,7 +380,11 @@ class Question:
                 ans = self.parse_answer(answer)
             except Exception:
                 return "Invalid input"
-            return self.validate_answer(ans) or True
+            try:
+                self.validate_answer(ans)
+            except Exception as exc:
+                return str(exc)
+            return True
 
         lexer = None
         result: AnyByStrDict = {
@@ -405,7 +407,14 @@ class Question:
                 result["default"] = False
         if self.choices:
             questionary_type = "checkbox" if self.multiselect else "select"
-            result["choices"] = self._formatted_choices
+            choices = self._formatted_choices
+            # Select default choices for a multiselect question.
+            if self.multiselect and isinstance(
+                default_choices := self.get_default(), list
+            ):
+                for choice in (choices := deepcopy(choices)):
+                    choice.checked = self.cast_answer(choice.value) in default_choices
+            result["choices"] = choices
         if questionary_type == "input":
             if self.secret:
                 questionary_type = "password"
@@ -436,15 +445,16 @@ class Question:
         """Get the value for multiline."""
         return cast_to_bool(self.render_value(self.multiline))
 
-    def validate_answer(self, answer: Any) -> str:
+    def validate_answer(self, answer: Any) -> None:
         """Validate user answer."""
         try:
             err_msg = self.render_value(self.validator, {self.var_name: answer}).strip()
         except Exception as error:
-            return str(error)
+            err_msg = str(error)
         if err_msg:
-            return err_msg
-        return ""
+            raise ValueError(
+                f"Validation error for question '{self.var_name}': {err_msg}"
+            )
 
     def get_when(self) -> bool:
         """Get skip condition for question."""
diff --git a/docs/configuring.md b/docs/configuring.md
index 65ab6e2..a3c097a 100644
--- a/docs/configuring.md
+++ b/docs/configuring.md
@@ -1426,6 +1426,19 @@ configuring `secret: true` in the [advanced prompt format][advanced-prompt-forma
     password: s3cr3t
     ```
 
+### `skip_answered`
+
+-   Format: `bool`
+-   CLI flags: `-A`, `--skip-answered` (only available in `copier update` subcommand)
+-   Default value: `False`
+
+When updating a project, skip asking questions that have already been answered and keep
+the recorded answer.
+
+!!! info
+
+    Not supported in `copier.yml`.
+
 ### `skip_if_exists`
 
 -   Format: `List[str]`
@@ -1708,7 +1721,7 @@ the `v2.0.0a1` tag unless this flag is enabled.
 
 ### `vcs_ref`
 
--   Format: `str`
+-   Format: `str | VcsRef`
 -   CLI flags: `-r`, `--vcs-ref`
 -   Default value: N/A (use latest release)
 
@@ -1725,8 +1738,10 @@ _commit: v1.0.0
 
     Not supported in `copier.yml`.
 
-By default, Copier will copy from the last release found in template Git tags, sorted as
-[PEP 440][].
+The special value `VcsRef.CURRENT` is set to indicate that the template version should
+be identical to the version already present. It is set when using `--vcs-ref=:current:`
+in the CLI. By default, Copier will copy from the last release found in template Git
+tags, sorted as [PEP 440][].
 
 ## Patterns syntax
 
diff --git a/docs/updating.md b/docs/updating.md
index fccac83..6d2cc7f 100644
--- a/docs/updating.md
+++ b/docs/updating.md
@@ -142,6 +142,12 @@ copier update --defaults --data-file /tmp/data-file.yaml
     it is not yet possible to update a multiselect choice using ˋ--dataˋ.
     Use ˋ--data-fileˋ instead for now.
 
+If you want to update the answers to all questions, but not the template:
+
+```shell
+copier update --vcs-ref=:current:
+```
+
 ## How the update works
 
 To understand how the updating process works, take a look at this diagram:
diff --git a/pyproject.toml b/pyproject.toml
index aece3f5..1654338 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ copier = "copier.__main__:CopierApp.run"
 
 [dependency-groups]
 dev = [
-  "commitizen==4.7.1",
+  "commitizen==4.7.2",
   "mypy==1.15.0",
   "pexpect==4.9.0",
   "poethepoet==0.34.0",
diff --git a/uv.lock b/uv.lock
index 9f2975f..7af60aa 100644
--- a/uv.lock
+++ b/uv.lock
@@ -157,7 +157,7 @@ wheels = [
 
 [[package]]
 name = "commitizen"
-version = "4.7.1"
+version = "4.7.2"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "argcomplete" },
@@ -173,9 +173,9 @@ dependencies = [
     { name = "tomlkit" },
     { name = "typing-extensions", marker = "python_full_version < '3.11'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/6c/dc/a993a0744fbdb81e08536f3f7f85cd479da54e5a74589e0f287b8add77a9/commitizen-4.7.1.tar.gz", hash = "sha256:7eba472ef582b1bf2480164901ed542173e83f4f885968e7bed0b8cd0f6dfb07", size = 53290, upload-time = "2025-05-16T02:50:59.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/2b/0dcd3ab025dafd3353cf8051f5859ce7f75b86e90e2b3c0395304fbbbfa3/commitizen-4.7.2.tar.gz", hash = "sha256:e5ca28f909c3b3d28575b294561feeee21e33c864c7362babfe0e7798ae11aae", size = 53356, upload-time = "2025-05-18T15:17:49.7Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/64/1e/30d492dc7c17727d0b08cffb40dab5a6fe86767cb9d96eeafe06d4163c26/commitizen-4.7.1-py3-none-any.whl", hash = "sha256:80b08328b9741483d2de01cd76d015e9f6dba62c0ae450d4f8606ee3cbf9aa0e", size = 76035, upload-time = "2025-05-16T02:50:57.904Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/8a/02db37d46e17f8f45fa0a61159296fa58b58a2303440e08bc797b942e659/commitizen-4.7.2-py3-none-any.whl", hash = "sha256:eb3cd2da0690c6edbb8c11e586d3f07b09e9795424aa51f5413ed89827927623", size = 76192, upload-time = "2025-05-18T15:17:48.39Z" },
 ]
 
 [[package]]
@@ -245,7 +245,7 @@ requires-dist = [
 
 [package.metadata.requires-dev]
 dev = [
-    { name = "commitizen", specifier = "==4.7.1" },
+    { name = "commitizen", specifier = "==4.7.2" },
     { name = "mypy", specifier = "==1.15.0" },
     { name = "pexpect", specifier = "==4.9.0" },
     { name = "poethepoet", specifier = "==0.34.0" },


Test patch:
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 2827866..144671b 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -852,93 +852,89 @@ def test_required_choice_question_without_data(
 
 
 @pytest.mark.parametrize(
-    "type_name, default, expected",
+    "spec, expected",
     [
-        ("str", "string", does_not_raise()),
-        ("str", "1.0", does_not_raise()),
-        ("str", 1.0, does_not_raise()),
-        ("str", None, pytest.raises(TypeError)),
+        ({"type": "str", "default": "string"}, does_not_raise()),
+        ({"type": "str", "default": "1.0"}, does_not_raise()),
+        ({"type": "str", "default": 1.0}, does_not_raise()),
+        ({"type": "str", "default": None}, pytest.raises(TypeError)),
         (
             {
                 "type": "str",
+                "default": "",
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            pytest.raises(ValueError),
+        ),
+        (
+            {
+                "type": "str",
+                "default": "",
                 "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()),
-        ("int", "1.0", pytest.raises(ValueError)),
-        ("int", "no-int", pytest.raises(ValueError)),
-        ("int", None, pytest.raises(TypeError)),
-        ("int", {}, pytest.raises(TypeError)),
-        ("int", [], pytest.raises(TypeError)),
-        ("float", 1.1, does_not_raise()),
-        ("float", 1, does_not_raise()),
-        ("float", "1.1", does_not_raise()),
-        ("float", "no-float", pytest.raises(ValueError)),
-        ("float", None, pytest.raises(TypeError)),
-        ("float", {}, pytest.raises(TypeError)),
-        ("float", [], pytest.raises(TypeError)),
-        ("bool", True, does_not_raise()),
-        ("bool", False, does_not_raise()),
-        ("bool", "y", does_not_raise()),
-        ("bool", "n", does_not_raise()),
-        ("bool", None, pytest.raises(TypeError)),
-        ("json", '"string"', does_not_raise()),
-        ("json", "1", does_not_raise()),
-        ("json", 1, does_not_raise()),
-        ("json", "1.1", does_not_raise()),
-        ("json", 1.1, does_not_raise()),
-        ("json", "true", does_not_raise()),
-        ("json", True, does_not_raise()),
-        ("json", "false", does_not_raise()),
-        ("json", False, does_not_raise()),
-        ("json", "{}", does_not_raise()),
-        ("json", {}, does_not_raise()),
-        ("json", "[]", does_not_raise()),
-        ("json", [], does_not_raise()),
-        ("json", "null", does_not_raise()),
-        ("json", None, does_not_raise()),
-        ("yaml", '"string"', does_not_raise()),
-        ("yaml", "string", does_not_raise()),
-        ("yaml", "1", does_not_raise()),
-        ("yaml", 1, does_not_raise()),
-        ("yaml", "1.1", does_not_raise()),
-        ("yaml", 1.1, does_not_raise()),
-        ("yaml", "true", does_not_raise()),
-        ("yaml", True, does_not_raise()),
-        ("yaml", "false", does_not_raise()),
-        ("yaml", False, does_not_raise()),
-        ("yaml", "{}", does_not_raise()),
-        ("yaml", {}, does_not_raise()),
-        ("yaml", "[]", does_not_raise()),
-        ("yaml", [], does_not_raise()),
-        ("yaml", "null", does_not_raise()),
-        ("yaml", None, does_not_raise()),
+        ({"type": "int", "default": 1}, does_not_raise()),
+        ({"type": "int", "default": 1.0}, does_not_raise()),
+        ({"type": "int", "default": "1"}, does_not_raise()),
+        ({"type": "int", "default": "1.0"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": "no-int"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": None}, pytest.raises(TypeError)),
+        ({"type": "int", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "int", "default": []}, pytest.raises(TypeError)),
+        ({"type": "float", "default": 1.1}, does_not_raise()),
+        ({"type": "float", "default": 1}, does_not_raise()),
+        ({"type": "float", "default": "1.1"}, does_not_raise()),
+        ({"type": "float", "default": "no-float"}, pytest.raises(ValueError)),
+        ({"type": "float", "default": None}, pytest.raises(TypeError)),
+        ({"type": "float", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "float", "default": []}, pytest.raises(TypeError)),
+        ({"type": "bool", "default": True}, does_not_raise()),
+        ({"type": "bool", "default": False}, does_not_raise()),
+        ({"type": "bool", "default": "y"}, does_not_raise()),
+        ({"type": "bool", "default": "n"}, does_not_raise()),
+        ({"type": "bool", "default": None}, pytest.raises(TypeError)),
+        ({"type": "json", "default": '"string"'}, does_not_raise()),
+        ({"type": "json", "default": "1"}, does_not_raise()),
+        ({"type": "json", "default": 1}, does_not_raise()),
+        ({"type": "json", "default": "1.1"}, does_not_raise()),
+        ({"type": "json", "default": 1.1}, does_not_raise()),
+        ({"type": "json", "default": "true"}, does_not_raise()),
+        ({"type": "json", "default": True}, does_not_raise()),
+        ({"type": "json", "default": "false"}, does_not_raise()),
+        ({"type": "json", "default": False}, does_not_raise()),
+        ({"type": "json", "default": "{}"}, does_not_raise()),
+        ({"type": "json", "default": {}}, does_not_raise()),
+        ({"type": "json", "default": "[]"}, does_not_raise()),
+        ({"type": "json", "default": []}, does_not_raise()),
+        ({"type": "json", "default": "null"}, does_not_raise()),
+        ({"type": "json", "default": None}, does_not_raise()),
+        ({"type": "yaml", "default": '"string"'}, does_not_raise()),
+        ({"type": "yaml", "default": "string"}, does_not_raise()),
+        ({"type": "yaml", "default": "1"}, does_not_raise()),
+        ({"type": "yaml", "default": 1}, does_not_raise()),
+        ({"type": "yaml", "default": "1.1"}, does_not_raise()),
+        ({"type": "yaml", "default": 1.1}, does_not_raise()),
+        ({"type": "yaml", "default": "true"}, does_not_raise()),
+        ({"type": "yaml", "default": True}, does_not_raise()),
+        ({"type": "yaml", "default": "false"}, does_not_raise()),
+        ({"type": "yaml", "default": False}, does_not_raise()),
+        ({"type": "yaml", "default": "{}"}, does_not_raise()),
+        ({"type": "yaml", "default": {}}, does_not_raise()),
+        ({"type": "yaml", "default": "[]"}, does_not_raise()),
+        ({"type": "yaml", "default": []}, does_not_raise()),
+        ({"type": "yaml", "default": "null"}, does_not_raise()),
+        ({"type": "yaml", "default": None}, does_not_raise()),
     ],
 )
 def test_validate_default_value(
     tmp_path_factory: pytest.TempPathFactory,
-    type_name: str,
-    default: Any,
+    spec: AnyByStrDict,
     expected: AbstractContextManager[None],
 ) -> None:
     src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
-    build_file_tree(
-        {
-            (src / "copier.yml"): yaml.dump(
-                {
-                    "q": {
-                        "type": type_name,
-                        "default": default,
-                    }
-                }
-            )
-        }
-    )
+    build_file_tree({(src / "copier.yml"): yaml.dump({"q": spec})})
     with expected:
         copier.run_copy(str(src), dst, defaults=True)
 
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 3e84f07..8a023a6 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -13,6 +13,7 @@ from plumbum import local
 from copier._cli import CopierApp
 from copier._main import Worker, run_copy, run_update
 from copier._tools import normalize_git_path
+from copier._types import VcsRef
 from copier._user_data import load_answersfile_data
 from copier.errors import UserMessageError
 
@@ -25,6 +26,7 @@ from .helpers import (
     build_file_tree,
     git,
     git_init,
+    git_save,
 )
 
 
@@ -1603,6 +1605,65 @@ def test_update_with_skip_answered_and_new_answer(
     assert answers["boolean"] is True
 
 
+@pytest.mark.parametrize("cli", [True, False])
+def test_update_vcs_ref_current(
+    tmp_path_factory: pytest.TempPathFactory,
+    cli: bool,
+) -> 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")
+
+    if cli:
+        CopierApp.run(
+            ["copier", "copy", str(src), str(dst), "--defaults", "--overwrite"],
+            exit=False,
+        )
+    else:
+        run_copy(str(src), dst, defaults=True, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers["_commit"] == "v1"
+    assert answers["boolean"] is False
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    with local.cwd(src):
+        build_file_tree({"README.md": "# Template Update"})
+        git_save(message="update template", tag="v2")
+
+    if cli:
+        with local.cwd(dst):
+            CopierApp.run(
+                [
+                    "copier",
+                    "update",
+                    "--data",
+                    "boolean=true",
+                    "--vcs-ref=:current:",
+                ],
+                exit=False,
+            )
+    else:
+        run_update(
+            dst, data={"boolean": "true"}, vcs_ref=VcsRef.CURRENT, overwrite=True
+        )
+    answers = load_answersfile_data(dst)
+    assert answers["_commit"] == "v1"
+    assert answers["boolean"] is True
+
+    # assert that the README.md file was not created
+    assert not (dst / "README.md").exists()
+
+
 def test_update_dont_validate_computed_value(
     tmp_path_factory: pytest.TempPathFactory,
 ) -> None: