Skip to content
Open
Show file tree
Hide file tree
Changes from all 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[untyped-decorator]
["-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
90 changes: 83 additions & 7 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -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]`
Expand Down
61 changes: 61 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading