Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions copier/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[misc]
["-d", "--data"],
Expand Down Expand Up @@ -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,
)

Expand Down
152 changes: 145 additions & 7 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import platform
import re
import stat
import subprocess
import sys
Expand All @@ -30,6 +31,7 @@
from unicodedata import normalize

from jinja2.loaders import FileSystemLoader
from packaging.version import InvalidVersion, parse as parse_version
from pathspec import PathSpec
from plumbum import ProcessExecutionError, colors
from plumbum.machines import local
Expand Down Expand Up @@ -218,11 +220,31 @@ 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)
# make sure to update documentation upon any changes.

# Floating tag patterns for automatic tag resolution
# These patterns identify tags/refs that are likely to move over time
_FLOATING_TAG_PATTERNS = [
r"^latest$",
r"^stable(/.*)?$",
r"^main$",
r"^master$",
r"^develop$",
r"^development$",
r"^HEAD$",
# Branch-like patterns
r"^[a-z]+[-_][a-z]+$", # e.g., feature-branch, dev-test
r"^(feat|fix|chore|docs|test|refactor)/.*$", # conventional branch names
Comment thread
maganaluis marked this conversation as resolved.
Outdated
]

src_path: str | None = None
dst_path: Path = Path()
answers_file: RelativePath | None = None
Expand All @@ -243,6 +265,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)
Expand Down Expand Up @@ -335,11 +358,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)
Expand All @@ -353,6 +404,88 @@ 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 _is_stable_semantic_version(self, ref: str) -> bool:
"""Detect if a reference is a stable semantic version or floating tag.

Args:
ref: The git reference to check (tag, branch, etc.)

Returns:
True if the reference is a stable semantic version, False otherwise.
"""
# Check custom stable patterns from template config first
# Use subproject.template (FROM version) to avoid circular dependency
# This is the template that was used originally, so its config is what matters
if self.subproject.template and self.subproject.template.stable_tag_patterns:
stable_patterns = self.subproject.template.stable_tag_patterns
for pattern in stable_patterns:
if re.match(pattern, ref):
return True
# If custom patterns defined but no match, it's not stable
return False

# Check against default floating tag patterns
for pattern in self._FLOATING_TAG_PATTERNS:
if re.match(pattern, ref, re.IGNORECASE):
return False

# Check if it's a valid semver tag
try:
parse_version(ref)
return True
except InvalidVersion:
return False

def _resolve_vcs_ref_for_update(
self, answers: dict[str, Any] | None = None
) -> str | None:
"""Resolve which VCS ref to use for update operations.

Implements automatic tag resolution to handle floating tags:
1. If --ignore-git-tags flag is set, always use SHA
2. If semantic version looks stable, use it
3. Otherwise, fallback to SHA for safety (floating tags)

Args:
answers: The answers dict to read from

Returns:
The resolved VCS reference (semantic version or SHA), or None if unavailable.
"""
# answers must be provided to avoid recursion
if answers is None:
return None

# No answers available (first copy)
if not answers.get("_commit") and not answers.get("_commit_sha"):
return None

# 1. CLI flag override (explicit user choice)
if self.ignore_git_tags:
return self._get_sha_from_answers(answers)

# 2. Automatic resolution: prefer semantic version if stable
semantic_version = answers.get("_commit")
if semantic_version:
semantic_str = str(semantic_version)
if self._is_stable_semantic_version(semantic_str):
return semantic_str

# 3. Fallback to SHA (for floating tags or when semantic version doesn't exist)
return self._get_sha_from_answers(answers)

def _execute_tasks(self, tasks: Sequence[Task]) -> None:
"""Run the given tasks.

Expand Down Expand Up @@ -423,6 +556,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,
}
Expand Down Expand Up @@ -988,12 +1122,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
Expand Down
17 changes: 13 additions & 4 deletions copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,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)
Expand Down
26 changes: 26 additions & 0 deletions copier/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,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]`
Expand Down
Loading