diff --git a/copier/_cli.py b/copier/_cli.py index 59316221c..90e3e4953 100644 --- a/copier/_cli.py +++ b/copier/_cli.py @@ -162,6 +162,11 @@ def __init__(self, executable: PathLike[str]) -> None: default=False, help="Skip template tasks execution", ) + ignore_git_tags = cli.Flag( + ["--ignore-git-tags"], + default=False, + help="Always use SHA commit hash instead of git tags/branches for updates", + ) @cli.switch( # type: ignore[untyped-decorator] ["-d", "--data"], @@ -226,6 +231,7 @@ def _worker( use_prereleases=self.prereleases, unsafe=self.unsafe, skip_tasks=self.skip_tasks, + ignore_git_tags=self.ignore_git_tags, **kwargs, ) diff --git a/copier/_main.py b/copier/_main.py index 9b1090b86..2d0d456e6 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -222,6 +222,11 @@ class Worker: skip_tasks: When `True`, skip template tasks execution. + + ignore_git_tags: + When `True`, always use SHA commit hash instead of git tags/branches + for update operations. Both semantic version and SHA are always stored + in the answers file; this flag controls which one is used for updates. """ # NOTE: attributes are fully documented in [creating.md](../docs/creating.md) @@ -247,6 +252,7 @@ class Worker: unsafe: bool = False skip_answered: bool = False skip_tasks: bool = False + ignore_git_tags: bool = False answers: AnswersMap = field(default_factory=AnswersMap, init=False) _cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False) @@ -339,11 +345,39 @@ def _answers_to_remember(self) -> Mapping[str, Any]: """Get only answers that will be remembered in the copier answers file.""" # All internal values must appear first answers: AnyByStrDict = {} - commit = self.template.commit - src = self.template.url - for key, value in (("_commit", commit), ("_src_path", src)): - if value is not None: - answers[key] = value + + # Always store both semantic version and SHA when available + if self.template.vcs == "git": + # For copy operations or explicit vcs_ref, use the specified ref + # For updates with :current:, use the resolved ref + if self.vcs_ref is VcsRef.CURRENT: + # During update with :current:, use resolved ref + semantic_version = self.resolved_vcs_ref or self.template.commit + elif self.vcs_ref == "HEAD": + # When user specifies HEAD, store the git describe output instead + # of the literal string "HEAD" for better human readability + semantic_version = self.template.commit + else: + # During copy or update with explicit ref (tag/branch name), use vcs_ref directly + semantic_version = self.vcs_ref or self.template.commit + sha_version = self.template.commit_hash + + # Dual versioning: Always store both values + # _commit stores semantic version (for human readability) + # _commit_sha stores SHA (for reliable resolution) + # The ignore_git_tags flag controls which one is USED during updates, + # not which one is STORED + + if semantic_version: + answers["_commit"] = semantic_version + + # Always store SHA separately for fallback + if sha_version: + answers["_commit_sha"] = sha_version + + # Store source path + if src := self.template.url: + answers["_src_path"] = src # Other data goes next answers.update( (str(k), v) @@ -357,6 +391,43 @@ def _answers_to_remember(self) -> Mapping[str, Any]: ) return answers + def _get_sha_from_answers(self, answers: dict[str, Any]) -> str | None: + """Extract SHA from answers dict. + + Args: + answers: The answers dictionary + + Returns: + The SHA as a string, or None if not available + """ + sha = answers.get("_commit_sha") + return str(sha) if sha else None + + def _resolve_vcs_ref_for_update( + self, answers: dict[str, Any] | None = None + ) -> str | None: + """Resolve which VCS ref to use for update operations. + + Args: + answers: The answers dict to read from + + Returns: + The resolved VCS reference (tag or SHA), or None if unavailable. + """ + if answers is None: + return None + + if not answers.get("_commit") and not answers.get("_commit_sha"): + return None + + # If ignore_git_tags is set, use SHA + if self.ignore_git_tags: + return self._get_sha_from_answers(answers) + + # Otherwise use stored tag, fallback to SHA + commit = answers.get("_commit") + return str(commit) if commit else self._get_sha_from_answers(answers) + def _execute_tasks(self, tasks: Sequence[Task]) -> None: """Run the given tasks. @@ -427,6 +498,7 @@ def _render_context(self) -> AnyByStrMutableMapping: "unsafe": lambda: self.unsafe, "skip_answered": lambda: self.skip_answered, "skip_tasks": lambda: self.skip_tasks, + "ignore_git_tags": lambda: self.ignore_git_tags, "sep": lambda: os.sep, "os": lambda: OS, } @@ -1016,12 +1088,16 @@ 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 `vcs_ref` is `VcsRef.CURRENT`. When using the subproject's + stored ref, applies automatic resolution to choose between semantic + version and SHA. """ if self.vcs_ref is VcsRef.CURRENT: if self.subproject.template is None: raise TypeError("Template not found") - return self.subproject.template.ref + # Use automatic resolution for updates - pass answers explicitly + resolved = self._resolve_vcs_ref_for_update(self.subproject.last_answers) + return resolved if resolved else self.subproject.template.ref return self.vcs_ref @cached_property diff --git a/copier/_subproject.py b/copier/_subproject.py index fbd2e5b6f..726385f01 100644 --- a/copier/_subproject.py +++ b/copier/_subproject.py @@ -63,18 +63,27 @@ def _raw_answers(self) -> AnyByStrDict: @cached_property def last_answers(self) -> AnyByStrDict: - """Last answers, excluding private ones (except _src_path and _commit).""" + """Last answers, excluding private ones (except _src_path, _commit, and _commit_sha).""" return { key: value for key, value in self._raw_answers.items() - if key in {"_src_path", "_commit"} or not key.startswith("_") + if key in {"_src_path", "_commit", "_commit_sha"} or not key.startswith("_") } @cached_property def template(self) -> Template | None: - """Template, as it was used the last time.""" + """Template, as it was used the last time. + + Uses the stored SHA if available to ensure we reference the exact + commit the project was created from, even if tags have moved. + This is critical for the update diff calculation. + """ last_url = self.last_answers.get("_src_path") - last_ref = self.last_answers.get("_commit") + # Prefer SHA for exact reference (prevents issues with moved tags) + # Fall back to _commit if SHA not available (backward compatibility) + last_ref = self.last_answers.get("_commit_sha") or self.last_answers.get( + "_commit" + ) if last_url: result = Template(url=last_url, ref=last_ref) self._cleanup_hooks.append(result._cleanup) diff --git a/copier/_template.py b/copier/_template.py index ce6313134..fe3106386 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -555,6 +555,32 @@ def preserve_symlinks(self) -> bool: """ return bool(self.config_data.get("preserve_symlinks", False)) + @cached_property + def ignore_git_tags(self) -> bool: + """Know if Copier should ignore git tags and use SHA instead. + + See [ignore_git_tags][]. + """ + return bool(self.config_data.get("ignore_git_tags", False)) + + @cached_property + def stable_tag_patterns(self) -> list[str] | None: + """Get custom regex patterns for stable tags. + + Template authors can define which tag patterns should be considered + stable semantic versions vs floating tags. + + See [stable_tag_patterns][]. + """ + patterns = self.config_data.get("_stable_tag_patterns") + if patterns is None: + return None + if isinstance(patterns, str): + return [patterns] + if isinstance(patterns, list): + return patterns + return None + @cached_property def local_abspath(self) -> Path: """Get the absolute path to the template on disk. diff --git a/docs/configuring.md b/docs/configuring.md index be596824e..c46ec70a2 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -1491,6 +1491,81 @@ Suppress status output. Not supported in `copier.yml`. +### `ignore_git_tags` + +- Format: `bool` +- CLI flags: `--ignore-git-tags` +- Default value: `False` + +Copier uses **dual-versioning** when copying from Git-versioned templates: it stores +BOTH the semantic version (tag/branch name) and the SHA commit hash in the answers file. +During updates, Copier applies **automatic tag resolution** to intelligently choose +which to use. + +By default, Copier automatically detects floating tags (like `latest`, `stable/v1`, +`main`) and uses SHA for those, while preserving semantic versions for stable tags (like +`v1.0.0`). + +If automatic tag resolution isn't working as expected, use the `--ignore-git-tags` flag +to force SHA usage for all updates, overriding automatic detection. + +**Automatic Tag Resolution** detects these floating patterns: + +- `latest`, `stable/*`, `main`, `master`, `develop` +- Branch-like patterns: `feat/*`, `fix/*`, `feature-*` + +For these patterns, Copier automatically uses SHA to ensure reproducible updates. For +stable semantic versions (like `v1.0.0`), it preserves the tag name. + +!!! info + + Template authors can force SHA usage for all projects by setting + `_ignore_git_tags: true` in `copier.yml`. The CLI flag takes precedence + over template configuration. + +!!! example "Automatic resolution (recommended)" + + ```shell + # No flag needed - automatic resolution handles everything + copier copy --vcs-ref v1.0.0 template destination + copier update # Automatically uses SHA for floating tags + + # With floating tag - automatically uses SHA for updates + copier copy --vcs-ref stable/v1 template destination + copier update # Updates work correctly even if tag moved + ``` + + The answers file contains both: + ```yaml + _commit: v1.0.0 # or stable/v1 + _commit_sha: a1b2c3d4e5f6... + ``` + +!!! example "Force SHA usage" + + Use this if automatic resolution isn't working or for strict reproducibility: + + ```shell + # Override automatic detection - always use SHA + copier copy --ignore-git-tags --vcs-ref v1.0.0 template destination + copier update --ignore-git-tags + ``` + +!!! example "Template configuration" + + Template authors can force SHA usage by default: + + ```yaml title="copier.yml" + _ignore_git_tags: true + ``` + + Or define custom stable tag patterns: + + ```yaml title="copier.yml" + _stable_tag_patterns: + - ^release/.*$ # Treat release/* as stable + ``` + ### `secret_questions` - Format: `List[str]` diff --git a/docs/faq.md b/docs/faq.md index 91add45dd..b72b17d9d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -215,6 +215,67 @@ $ 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 does Copier handle floating tags? + +Copier uses **dual-versioning** with **automatic tag resolution** to handle both stable +and floating tags intelligently. No flag is needed in most cases. + +### What is dual-versioning? + +When copying from a Git template, Copier stores BOTH values in your answers file: + +- `_commit`: The semantic version (tag/branch name) - human-readable +- `_commit_sha`: The SHA commit hash - immutable reference + +During updates, Copier automatically chooses which to use based on whether the tag is +"floating" (movable) or "stable" (fixed). + +### Automatic tag resolution + +Copier automatically detects floating tags like: + +- `latest`, `stable/*`, `main`, `master`, `develop` +- Branch patterns: `feat/*`, `fix/*`, `feature-*` + +For these, Copier uses SHA during updates to ensure reproducibility. For stable semantic +versions (like `v1.0.0`), it preserves the tag name. + +```shell +# No special flag needed - automatic resolution handles it +copier copy --vcs-ref stable/v1 template destination +copier update # Works correctly even if tag moved +``` + +### When to use `--ignore-git-tags` + +Use this flag to force SHA usage for ALL updates, overriding automatic detection: + +- **When automatic resolution isn't working**: If updates fail with floating tags, use + this flag to force SHA usage +- **For strict compliance/reproducibility**: When you need guaranteed immutable + references for all updates + +```shell +# Force SHA usage +copier copy --ignore-git-tags --vcs-ref v2.0.0 template destination +copier update --ignore-git-tags +``` + +### Template configuration + +Template authors can force SHA usage or define custom stable patterns: + +```yaml title="copier.yml" +# Force SHA usage for all projects +_ignore_git_tags: true + +# Or define custom stable patterns +_stable_tag_patterns: + - ^release/.*$ +``` + +See [ignore_git_tags][] for more details. + ## How to pass credentials to Git? If you do something like this, and the template supports updates, you'll notice that the diff --git a/docs/updating.md b/docs/updating.md index 6d2cc7f7f..f38d8f4de 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -36,6 +36,16 @@ This will read all available Git tags, will compare them using before updating. To update to the latest commit, add `--vcs-ref=HEAD`. You can use any other Git ref you want. +!!! tip "Handling floating tags" + + Copier automatically handles floating tags (like `stable/*`, `latest`, `main`) using + **automatic tag resolution**. It stores both the semantic version and SHA, then + intelligently chooses which to use during updates. No special flags needed - updates + work correctly even if tags move. + + If automatic resolution isn't working as expected, use the `--ignore-git-tags` flag to + force SHA usage for all updates. See [ignore_git_tags][] for more details. + When updating, Copier will do its best to respect your project evolution by using the answers you provided when copied last time. However, sometimes it's impossible for Copier to know what to do with a diff code hunk. In those cases, copier handles the diff --git a/tests/test_answersfile.py b/tests/test_answersfile.py index f738c0f1c..8d2e27432 100644 --- a/tests/test_answersfile.py +++ b/tests/test_answersfile.py @@ -178,13 +178,13 @@ def test_external_data(tmp_path_factory: pytest.TempPathFactory) -> None: copier.run_copy(str(parent1), dst, defaults=True, overwrite=True) git_save(dst) assert (dst / "parent1.txt").read_text() == "P1" - expected_parent1_answers = { - "_src_path": str(parent1), - "_commit": "v1.0+parent1", - "name": "P1", - "child": "C1", - } - assert load_answersfile_data(dst, ".copier-answers.yml") == expected_parent1_answers + parent1_answers = load_answersfile_data(dst, ".copier-answers.yml") + assert parent1_answers["_src_path"] == str(parent1) + assert parent1_answers["_commit"] == "v1.0+parent1" + assert parent1_answers["name"] == "P1" + assert parent1_answers["child"] == "C1" + # SHA is also stored in new dual-versioning system + assert "_commit_sha" in parent1_answers # Apply parent 2. It uses a different answers file. copier.run_copy( str(parent2), @@ -195,14 +195,12 @@ def test_external_data(tmp_path_factory: pytest.TempPathFactory) -> None: ) git_save(dst) assert (dst / "parent2.txt").read_text() == "P2" - expected_parent2_answers = { - "_commit": "v1.0+parent2", - "_src_path": str(parent2), - "name": "P2", - } - assert ( - load_answersfile_data(dst, ".parent2-answers.yml") == expected_parent2_answers - ) + parent2_answers = load_answersfile_data(dst, ".parent2-answers.yml") + assert parent2_answers["_commit"] == "v1.0+parent2" + assert parent2_answers["_src_path"] == str(parent2) + assert parent2_answers["name"] == "P2" + + assert "_commit_sha" in parent2_answers # Apply child. It can access answers from both parents. copier.run_copy( str(child), @@ -212,12 +210,13 @@ def test_external_data(tmp_path_factory: pytest.TempPathFactory) -> None: answers_file=".child-answers.yml", ) git_save(dst) - assert load_answersfile_data(dst, ".child-answers.yml") == { - "_commit": "v1.0+child", - "_src_path": str(child), - "name": "C1", - "parent2_answers": ".parent2-answers.yml", - } + child_answers = load_answersfile_data(dst, ".child-answers.yml") + assert child_answers["_commit"] == "v1.0+child" + assert child_answers["_src_path"] == str(child) + assert child_answers["name"] == "C1" + assert child_answers["parent2_answers"] == ".parent2-answers.yml" + + assert "_commit_sha" in child_answers assert json.loads((dst / "combined.json").read_text()) == { "parent1": "P1", "parent2": "P2", diff --git a/tests/test_complex_questions.py b/tests/test_complex_questions.py index c6c8f9ba8..fcdf19789 100644 --- a/tests/test_complex_questions.py +++ b/tests/test_complex_questions.py @@ -417,14 +417,14 @@ def test_tui_inherited_default( tui.expect_exact("example") tui.sendline("2") tui.expect_exact(pexpect.EOF) - result = { - "_commit": "1", - "_src_path": str(src), - "has_2_owners": has_2_owners, - "owner1": "example", - **({"owner2": owner2} if has_2_owners else {}), - } - assert json.loads((dst / "answers.json").read_text()) == result + answers = json.loads((dst / "answers.json").read_text()) + assert answers["_commit"] == "1" + assert answers["_src_path"] == str(src) + assert answers["has_2_owners"] == has_2_owners + assert answers["owner1"] == "example" + assert "_commit_sha" in answers + if has_2_owners: + assert answers["owner2"] == owner2 assert json.loads((dst / "context.json").read_text()) == {"owner2": owner2} with local.cwd(dst): git("init") @@ -432,7 +432,14 @@ def test_tui_inherited_default( git("commit", "--message", "init project") # After a forced update, answers stay the same run_update(dst, defaults=True, overwrite=True) - assert json.loads((dst / "answers.json").read_text()) == result + updated_answers = json.loads((dst / "answers.json").read_text()) + assert updated_answers["_commit"] == "1" + assert updated_answers["_src_path"] == str(src) + assert updated_answers["has_2_owners"] == has_2_owners + assert updated_answers["owner1"] == "example" + assert "_commit_sha" in updated_answers + if has_2_owners: + assert updated_answers["owner2"] == owner2 def test_tui_typed_default( diff --git a/tests/test_ignore_git_tags.py b/tests/test_ignore_git_tags.py new file mode 100644 index 000000000..2aab85685 --- /dev/null +++ b/tests/test_ignore_git_tags.py @@ -0,0 +1,587 @@ +"""Tests for the ignore_git_tags flag functionality. + +This test suite focuses on: +1. Dual-versioning storage (both _commit and _commit_sha are stored) +2. Automatic tag resolution during updates (floating vs stable tags) +3. CLI flag and template config behavior during updates +4. End-to-end floating tag scenarios +""" + +from __future__ import annotations + +import pytest +import yaml +from plumbum import local + +import copier +from copier import run_copy + +from .helpers import build_file_tree, git + + +@pytest.mark.parametrize( + "vcs_ref,ignore_git_tags", + [ + ("v1.0.0", True), + ("v1.0.0", False), + ("stable/v1", True), + ("stable/v1", False), + ("HEAD", True), + ("feature-branch", True), + ], +) +def test_dual_versioning_storage( + tmp_path_factory: pytest.TempPathFactory, + vcs_ref: str, + ignore_git_tags: bool, +) -> None: + """Test that both semantic version and SHA are always stored during copy. + + This is the core dual-versioning behavior: STORAGE is independent of the flag. + The flag only controls USAGE during updates. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): "_answers_file: .copier-answers.yml", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "file.txt"): "content", + } + ) + + # Create a git repo with various refs + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Initial commit") + git("tag", "v1.0.0") + git("tag", "stable/v1") + git("checkout", "-b", "feature-branch") + commit_hash = git("rev-parse", "HEAD").strip() + + # Copy with the specified ref + run_copy( + str(src), + dst, + vcs_ref=vcs_ref, + ignore_git_tags=ignore_git_tags, + defaults=True, + ) + + # Check that both semantic version and SHA are stored + answers_file = dst / ".copier-answers.yml" + answers = yaml.safe_load(answers_file.read_text()) + + # Both fields should be present + assert "_commit" in answers + assert "_commit_sha" in answers + + # SHA should be the actual commit hash + assert answers["_commit_sha"] == commit_hash + + # _commit should be semantic (not the SHA) + assert answers["_commit"] != commit_hash + + +def test_update_with_floating_tag_and_automatic_resolution( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test the CORE floating tag issue: automatic tag resolution during update. + + Scenario: + 1. Create project with floating tag "stable/v1" pointing to v1.0.0 + 2. Update template to v2.0.0 and move "stable/v1" tag + 3. Run copier update (VcsRef.CURRENT, no flags) + 4. Automatic resolution should detect "stable/v1" is floating + 5. Should use SHA (v1.0.0) as FROM version + 6. Should apply changes from v1.0.0 → v2.0.0 + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): "_answers_file: .copier-answers.yml", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + (src / "README.md"): "# Version 1.0.0", + } + ) + + # Create template at v1.0.0 with floating tag + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git("tag", "v1.0.0") + git("tag", "stable/v1") # Floating tag + first_commit = git("rev-parse", "HEAD").strip() + + # Copy project using the floating tag + run_copy( + str(src), + dst, + vcs_ref="stable/v1", + defaults=True, + ) + + # Verify initial state + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "stable/v1" + assert answers["_commit_sha"] == first_commit + assert (dst / "version.txt").read_text() == "1.0.0" + + # Initialize dst as git repo for update + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m", "Initial project state") + + # Update template to v2.0.0 and MOVE the floating tag + with local.cwd(src): + (src / "version.txt").write_text("2.0.0") + (src / "README.md").write_text("# Version 2.0.0") + (src / "workflow.yml").write_text("name: ci\non: [push]") + git("add", ".") + git("commit", "-m", "Version 2.0.0") + git("tag", "v2.0.0") + git("tag", "-f", "stable/v1") # Move floating tag to v2.0.0 + second_commit = git("rev-parse", "HEAD").strip() + + # Run update WITHOUT specifying vcs_ref (uses VcsRef.CURRENT) + # Automatic resolution should: + # 1. See "stable/v1" is a floating tag pattern + # 2. Use SHA (first_commit) as FROM version + # 3. Use current HEAD as TO version + # 4. Calculate diff and apply changes + copier.run_update( + dst, + defaults=True, + overwrite=True, + conflict="inline", + ) + + # Verify update worked + updated_answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + + # The _commit should still be "stable/v1" (semantic version preserved) + assert updated_answers["_commit"] == "stable/v1" + + # But _commit_sha should be updated to the new commit + assert updated_answers["_commit_sha"] == second_commit + + # Most importantly: changes should be applied + assert (dst / "version.txt").read_text() == "2.0.0" + assert (dst / "README.md").read_text() == "# Version 2.0.0" + assert (dst / "workflow.yml").exists() + + +def test_update_with_ignore_git_tags_flag( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that --ignore-git-tags flag forces SHA usage during update. + + Even with a stable semantic version tag, the flag should force using SHA. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): "_answers_file: .copier-answers.yml", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + } + ) + + # Create template with semantic version tags + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git("tag", "v1.0.0") + first_commit = git("rev-parse", "HEAD").strip() + + # Add v2.0.0 + (src / "version.txt").write_text("2.0.0") + git("add", ".") + git("commit", "-m", "Version 2.0.0") + git("tag", "v2.0.0") + + # Initial copy + run_copy( + str(src), + dst, + vcs_ref="v1.0.0", + ignore_git_tags=True, + defaults=True, + ) + + # Verify initial state + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1.0.0" + assert answers["_commit_sha"] == first_commit + assert (dst / "version.txt").read_text() == "1.0.0" + + # Initialize dst as git repo + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m", "Initial project state") + + # Update with --ignore-git-tags flag + # This should force using SHA, even though v1.0.0 is a stable semver + copier.run_update( + dst, + vcs_ref="v2.0.0", + ignore_git_tags=True, + defaults=True, + overwrite=True, + ) + + # Verify update worked + assert (dst / "version.txt").read_text() == "2.0.0" + updated_answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert updated_answers["_commit"] == "v2.0.0" + + +def test_update_with_stable_semantic_version( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that stable semantic versions are preserved during update. + + Automatic resolution should recognize v1.0.0 as stable and allow normal updates. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): "_answers_file: .copier-answers.yml", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + } + ) + + # Create template with proper semantic versions + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git("tag", "v1.0.0") + + (src / "version.txt").write_text("2.0.0") + git("add", ".") + git("commit", "-m", "Version 2.0.0") + git("tag", "v2.0.0") + + # Initial copy with stable semver + run_copy( + str(src), + dst, + vcs_ref="v1.0.0", + defaults=True, + ) + + # Initialize dst as git repo + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m", "Initial project state") + + # Update WITHOUT flags (VcsRef.CURRENT) + # Automatic resolution should see v1.0.0 is stable and use it normally + copier.run_update( + dst, + vcs_ref="v2.0.0", + defaults=True, + overwrite=True, + ) + + # Verify update worked + assert (dst / "version.txt").read_text() == "2.0.0" + + +def test_update_with_template_config_ignore_git_tags( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that template _ignore_git_tags config is respected during update. + + The template config should force SHA usage even without CLI flag. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): ( + "_answers_file: .copier-answers.yml\n_ignore_git_tags: true" + ), + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + } + ) + + # Create template with floating tag + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git("tag", "stable/v1") + first_commit = git("rev-parse", "HEAD").strip() + + # Copy at first commit (before tag moves) + run_copy( + str(src), + dst, + vcs_ref="stable/v1", + ignore_git_tags=False, # CLI flag OFF + defaults=True, + ) + + # Verify both are stored + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "stable/v1" + assert answers["_commit_sha"] == first_commit + + # Now update template and move the tag + with local.cwd(src): + (src / "version.txt").write_text("2.0.0") + git("add", ".") + git("commit", "-m", "Version 2.0.0") + git("tag", "-f", "stable/v1") + + # Initialize dst as git repo + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m", "Initial project state") + + # Update WITHOUT CLI flag + # Template config (_ignore_git_tags: true) should force SHA usage + copier.run_update( + dst, + defaults=True, + overwrite=True, + ) + + # Verify update worked (template config forced SHA usage) + assert (dst / "version.txt").read_text() == "2.0.0" + + +def test_cli_flag_overrides_template_config( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that CLI flag overrides template configuration. + + CLI --ignore-git-tags=true should override template _ignore_git_tags: false + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): ( + "_answers_file: .copier-answers.yml\n_ignore_git_tags: false" + ), + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + } + ) + + # Create template + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git("tag", "v1.0.0") + commit_hash = git("rev-parse", "HEAD").strip() + + # Copy with CLI flag=True (overrides template config=false) + run_copy( + str(src), + dst, + vcs_ref="v1.0.0", + ignore_git_tags=True, # Override template + defaults=True, + ) + + # Verify dual storage + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1.0.0" + assert answers["_commit_sha"] == commit_hash + + +def test_non_git_template( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that ignore_git_tags has no effect on non-git templates.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): "_answers_file: .copier-answers.yml", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "file.txt"): "content", + } + ) + + # Copy without git (local directory) + run_copy( + str(src), + dst, + ignore_git_tags=True, # Flag is set but should have no effect + defaults=True, + ) + + # Check that no _commit is stored (non-git template) + answers_file = dst / ".copier-answers.yml" + answers = yaml.safe_load(answers_file.read_text()) + assert "_commit" not in answers or answers["_commit"] is None + + +def test_automatic_resolution_patterns( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that automatic resolution correctly identifies floating tag patterns. + + Floating patterns that should trigger SHA fallback: + - latest + - stable, stable/*, stable-* + - main, master, develop, development + - HEAD + - Branch-like patterns (feature-*, feat/*, etc.) + """ + floating_tags = [ + "latest", + "stable", + "stable/v1", + "stable-release", + "main", + "master", + "develop", + # Note: "HEAD" is excluded because it's a reserved git reference name + # and cannot be created as a tag. HEAD is handled by automatic resolution + # but doesn't need a tag-based test. + "feature-auth", + "feat/new-feature", + ] + + for tag in floating_tags: + # Create fresh src and dst for each test to avoid tag conflicts + src_test = tmp_path_factory.mktemp(f"src_{tag.replace('/', '_')}") + dst_test = tmp_path_factory.mktemp(f"dst_{tag.replace('/', '_')}") + + build_file_tree( + { + (src_test / "copier.yml"): "_answers_file: .copier-answers.yml", + (src_test / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src_test / "version.txt"): "1.0.0", + } + ) + + # Create git repo with the floating tag + with local.cwd(src_test): + git("init") + git("add", ".") + git("commit", "-m", "Initial") + git("tag", tag) + + # Update and move tag + (src_test / "version.txt").write_text("2.0.0") + git("add", ".") + git("commit", "-m", "Update") + git("tag", "-f", tag) + + # Copy using the floating tag + run_copy( + str(src_test), + dst_test, + vcs_ref=tag, + defaults=True, + ) + + # Initialize as git repo + with local.cwd(dst_test): + git("init") + git("add", ".") + git("commit", "-m", "Initial") + + # Update - automatic resolution should use SHA + copier.run_update( + dst_test, + defaults=True, + overwrite=True, + ) + + # Verify update applied changes + assert (dst_test / "version.txt").read_text() == "2.0.0", ( + f"Automatic resolution failed for floating tag: {tag}" + ) + + +def test_custom_stable_patterns_in_template_config( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test that custom _stable_tag_patterns from template config work. + + Template can define custom patterns for what it considers "stable". + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): ( + "_answers_file: .copier-answers.yml\n" + "_stable_tag_patterns:\n" + " - ^release/.*$\n" # Custom: treat "release/*" as stable + ), + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + (src / "version.txt"): "1.0.0", + } + ) + + # Create template with custom "stable" tag pattern + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m", "Version 1.0.0") + git( + "tag", "release/1.0.0" + ) # Would normally be floating, but config says stable + + (src / "version.txt").write_text("2.0.0") + git("add", ".") + git("commit", "-m", "Version 2.0.0") + git("tag", "release/2.0.0") + + # Copy using the custom pattern tag + run_copy( + str(src), + dst, + vcs_ref="release/1.0.0", + defaults=True, + ) + + # Initialize as git repo + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m", "Initial") + + # Update - should treat release/1.0.0 as stable (per template config) + copier.run_update( + dst, + vcs_ref="release/2.0.0", + defaults=True, + overwrite=True, + ) + + # Verify update worked + assert (dst / "version.txt").read_text() == "2.0.0" diff --git a/tests/test_legacy_migration.py b/tests/test_legacy_migration.py index 82cffe0d0..4dc991fae 100644 --- a/tests/test_legacy_migration.py +++ b/tests/test_legacy_migration.py @@ -59,7 +59,11 @@ def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None: assert not list(dst.glob("*-before.txt")) assert not list(dst.glob("*-after.txt")) answers = load_answersfile_data(dst) - assert answers == {"_commit": "v1.0.0", "_src_path": str(src)} + # Check required fields are present + assert answers["_commit"] == "v1.0.0" + assert answers["_src_path"] == str(src) + # SHA is also stored in new dual-versioning system + assert "_commit_sha" in answers # Save changes in downstream repo with local.cwd(dst): git("init") @@ -89,7 +93,9 @@ def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None: assert (dst / "PEP440-1.0.0-2-2.0-before.json").is_file() assert (dst / "PEP440-1.0.0-2-2.0-after.json").is_file() answers = load_answersfile_data(dst) - assert answers == {"_commit": "v2.0", "_src_path": str(src)} + assert answers["_commit"] == "v2.0" + assert answers["_src_path"] == str(src) + assert "_commit_sha" in answers def test_pre_migration_modifies_answers( diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py index 623d9069e..20e3b5917 100644 --- a/tests/test_templated_prompt.py +++ b/tests/test_templated_prompt.py @@ -471,12 +471,12 @@ def test_templated_prompt_update_previous_answer_disabled( tui.sendline(Keyboard.Down) # select "Cloud Formation" tui.expect_exact(pexpect.EOF) - assert load_answersfile_data(dst) == { - "_src_path": str(src), - "_commit": "v1", - "cloud": "AWS", - "iac": "cf", - } + answers = load_answersfile_data(dst) + assert answers["_src_path"] == str(src) + assert answers["_commit"] == "v1" + assert answers["cloud"] == "AWS" + assert answers["iac"] == "cf" + assert "_commit_sha" in answers with local.cwd(dst): git_init("v1") @@ -488,12 +488,12 @@ def test_templated_prompt_update_previous_answer_disabled( tui.sendline() # select "Terraform" (first supported) tui.expect_exact(pexpect.EOF) - assert load_answersfile_data(dst) == { - "_src_path": str(src), - "_commit": "v1", - "cloud": "Azure", - "iac": "tf", - } + updated_answers = load_answersfile_data(dst) + assert updated_answers["_src_path"] == str(src) + assert updated_answers["_commit"] == "v1" + assert updated_answers["cloud"] == "Azure" + assert updated_answers["iac"] == "tf" + assert "_commit_sha" in updated_answers def test_multiselect_choices_with_templated_default_value( diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index 8722a998c..a99516d89 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -172,12 +172,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None: exit=False, ) # Check it's copied OK - assert load_answersfile_data(target) == { - "_commit": "v0.0.1", - "_src_path": str(bundle), - "author_name": "Guybrush", - "project_name": "to become a pirate", - } + answers = load_answersfile_data(target) + assert answers["_commit"] == "v0.0.1" + assert answers["_src_path"] == str(bundle) + assert answers["author_name"] == "Guybrush" + assert answers["project_name"] == "to become a pirate" + assert "_commit_sha" in answers assert readme.read_text() == dedent( """ Let me introduce myself. @@ -208,12 +208,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None: commit("-m", "I prefer grog") # Update target to latest tag and check it's updated in answers file CopierApp.run(["copier", "update", "--defaults", "--UNSAFE"], exit=False) - assert load_answersfile_data(target) == { - "_commit": "v0.0.2", - "_src_path": str(bundle), - "author_name": "Guybrush", - "project_name": "to become a pirate", - } + answers = load_answersfile_data(target) + assert answers["_commit"] == "v0.0.2" + assert answers["_src_path"] == str(bundle) + assert answers["author_name"] == "Guybrush" + assert answers["project_name"] == "to become a pirate" + assert "_commit_sha" in answers # Check migrations were executed properly assert not (target / "before-v0.0.1").is_file() assert not (target / "after-v0.0.1").is_file() @@ -237,12 +237,12 @@ def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None: assert not (target / "before-v1.0.0").is_file() assert not (target / "after-v1.0.0").is_file() # Check it's updated OK - assert load_answersfile_data(target) == { - "_commit": last_commit, - "_src_path": str(bundle), - "author_name": "Guybrush", - "project_name": "to become a pirate", - } + answers = load_answersfile_data(target) + assert answers["_commit"] == last_commit + assert answers["_src_path"] == str(bundle) + assert answers["author_name"] == "Guybrush" + assert answers["project_name"] == "to become a pirate" + assert "_commit_sha" in answers assert readme.read_text() == dedent( """ Let me introduce myself. @@ -2075,7 +2075,9 @@ def test_disable_secret_validator_on_replay( run_copy(str(src), dst, defaults=True) answers = load_answersfile_data(dst) - assert answers == {"_src_path": str(src), "_commit": "v1"} + assert answers["_src_path"] == str(src) + assert answers["_commit"] == "v1" + assert "_commit_sha" in answers assert (dst / ".env").read_text() == "TOKEN=" with local.cwd(dst): @@ -2083,7 +2085,9 @@ def test_disable_secret_validator_on_replay( run_update(dst, data={"token": "$up3r-$3cr3t"}, overwrite=True) answers = load_answersfile_data(dst) - assert answers == {"_src_path": str(src), "_commit": "v1"} + assert answers["_src_path"] == str(src) + assert answers["_commit"] == "v1" + assert "_commit_sha" in answers assert (dst / ".env").read_text() == "TOKEN=$up3r-$3cr3t"