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
7 changes: 7 additions & 0 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ class Worker:

answers: AnswersMap = field(default_factory=AnswersMap, init=False)
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
_update_stage: str = field(default="current", init=False)

def __enter__(self) -> Worker:
"""Allow using worker as a context manager."""
Expand Down Expand Up @@ -363,6 +364,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
for i, task in enumerate(tasks):
extra_context = {f"_{k}": v for k, v in task.extra_vars.items()}
extra_context["_copier_operation"] = operation
extra_context["_update_stage"] = self._update_stage

if not cast_to_bool(self._render_value(task.condition, extra_context)):
continue
Expand Down Expand Up @@ -1062,6 +1064,8 @@ def run_copy(self) -> None:
# TODO Unify printing tools
print("") # padding space
if not self.skip_tasks:
with Phase.use(Phase.POSTRENDER):
self._execute_tasks(self.template.postrender_tasks)
with Phase.use(Phase.TASKS):
self._execute_tasks(self.template.tasks)
except Exception:
Expand Down Expand Up @@ -1179,6 +1183,7 @@ def _apply_update(self) -> None: # noqa: C901
# https://github.com/orgs/copier-org/discussions/2345
exclude=[*self.template.exclude, *self.exclude],
) as old_worker:
old_worker._update_stage = "previous"
old_worker.run_copy()
# Run pre-migration tasks
with Phase.use(Phase.MIGRATE):
Expand Down Expand Up @@ -1236,6 +1241,7 @@ def _apply_update(self) -> None: # noqa: C901
# TODO
quiet=True,
) as current_worker:
current_worker._update_stage = "current"
current_worker.run_copy()
self.answers = current_worker.answers
self.answers.external = self._external_data()
Expand All @@ -1254,6 +1260,7 @@ def _apply_update(self) -> None: # noqa: C901
exclude=exclude_plus_removed,
vcs_ref=self.resolved_vcs_ref,
) as new_worker:
new_worker._update_stage = "new"
new_worker.run_copy()
with local.cwd(new_copy):
self._git_initialize_repo()
Expand Down
26 changes: 26 additions & 0 deletions copier/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,32 @@ def tasks(self) -> Sequence[Task]:
tasks.append(Task(cmd=task, extra_vars=extra_vars))
return tasks

@cached_property
def postrender_tasks(self) -> Sequence[Task]:
"""Get postrender tasks defined in the template.

These run after template rendering is complete but before regular
tasks execute. During updates, they run on both old_copy and new_copy
to ensure consistent transformations before diff calculation.

See [postrender_tasks][].
"""
extra_vars = {"stage": "postrender"}
tasks = []
for task in self.config_data.get("postrender_tasks", []):
if isinstance(task, dict):
tasks.append(
Task(
cmd=task["command"],
extra_vars=extra_vars,
condition=task.get("when", "true"),
working_directory=Path(task.get("working_directory", ".")),
)
)
else:
tasks.append(Task(cmd=task, extra_vars=extra_vars))
return tasks

@cached_property
def templates_suffix(self) -> str:
"""Get the suffix defined for templates.
Expand Down
3 changes: 2 additions & 1 deletion copier/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ class Phase(str, Enum):
"""The known execution phases."""

PROMPT = "prompt"
RENDER = "render"
POSTRENDER = "postrender"
TASKS = "tasks"
MIGRATE = "migrate"
RENDER = "render"
UNDEFINED = "undefined"

def __str__(self) -> str:
Expand Down
112 changes: 112 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,15 @@ Commands to execute after generating or updating a project from your template.
They run ordered, and with the `$STAGE=task` variable in their environment. Each task
runs in its own subprocess.

**Available variables:**

- All template variables (from questions and defaults)
- `_copier_phase` = "tasks"
- `_copier_operation` = "copy" or "update"
- `_update_stage` = "current" during copy; "previous", "current", or "new" during
update (see [postrender_tasks][] for details)
- `_stage` = "task" (available as `STAGE` environment variable)

If a `dict` is given it can contain the following items:

- **command**: The task command to run.
Expand Down Expand Up @@ -1686,6 +1695,109 @@ Refer to the example provided below for more information.
your task manager. But it's just an example. The point is that we're showing
how to build and call commands.

### `postrender_tasks`

!!! info Available since Copier 9.11.0

- Format: `List[str|List[str]|dict]`
- CLI flags: N/A
- Default value: `[]`

Postrender tasks are commands that run after template rendering is complete but before
regular tasks execute.

During copy operations, postrender tasks execute once after all files are rendered.

During update operations, postrender tasks execute three times:

1. After rendering the old template version (into temporary directory)
2. After rendering the new template version (into temporary directory)
3. After rendering to the actual destination

This ensures that any transformations are applied consistently before diff calculation,
maintaining correct 3-way merge semantics.

**Use cases:**

- Package refactoring (e.g., Java package renaming)
- Path normalization
- File transformations that should be consistent across template versions

If a `dict` is given it can contain the following items:

- **command**: The task command to run.
- **when** (optional): Specifies a condition that needs to hold for the task to run.
- **working_directory** (optional): Specifies the directory in which the command will
be run. Defaults to the destination directory.

If a `str` or `List[str]` is given as a postrender task it will be treated as `command`
with all other items not present.

**Available variables:**

- All template variables (from questions and defaults)
- `_copier_phase` = "postrender"
- `_copier_operation` = "copy" or "update"
- `_update_stage` = "current" during copy; "previous", "current", or "new" during
update
- `_stage` = "postrender" (available as `STAGE` environment variable)

**Execution:**

- Postrender tasks respect the [`skip_tasks`][skip_tasks] flag
- Task failures will abort the copy/update operation
- Tasks run in the destination directory by default

!!! warning "Postrender tasks must be idempotent"

During updates, postrender tasks run three times in different contexts:

- **`previous` stage**: Renders old template into clean temp directory
- **`new` stage**: Renders new template into clean temp directory
- **`current` stage**: Renders to actual destination (which already has previous postrender results!)

This means simple commands like `mv src/example src/{{ package }}` will fail during
updates because the destination directory already exists. Use the `_update_stage` variable to
apply different logic based on stage, or write fully idempotent tasks.

!!! example "Simple idempotent task"

```yaml title="copier.yml"
_postrender_tasks:
- "echo 'Postrender complete'"
- ["{{ _copier_python }}", "refactor.py", "example", "{{ package }}"]
```

!!! example "Directory renaming with `_update_stage`"

When renaming directories, use conditional logic based on `_update_stage`:

```yaml title="copier.yml"
package:
type: str
help: Package name

_postrender_tasks:
# Temp directories: always clean, simple rename works
- command: "[ -d src/example ] && mv src/example src/{{ package }} || true"
when: "{{ _update_stage in ['previous', 'new'] }}"

# Destination: must handle existing renamed directory from previous render
- command: |
if [ -d src/example ]; then
if [ ! -d "src/{{ package }}" ]; then
# Initial copy: simple rename
mv src/example "src/{{ package }}"
else
# Update: merge new template files into existing directory
mkdir -p "src/{{ package }}"
cp -R src/example/* "src/{{ package }}/"
rm -rf src/example
fi
fi
when: "{{ _update_stage == 'current' }}"
```

### `templates_suffix`

- Format: `str`
Expand Down
31 changes: 29 additions & 2 deletions docs/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ The name of the project root directory.

### `_copier_phase`

The current phase, one of `"prompt"`,`"tasks"`, `"migrate"` or `"render"`.
The current phase, one of `"prompt"`, `"render"`, `"postrender"`, `"tasks"`, or
`"migrate"`.

!!! note

Expand All @@ -167,7 +168,33 @@ Some variables are only available in select contexts:

The current operation, either `"copy"` or `"update"`.

Availability: [`exclude`](configuring.md#exclude), [`tasks`](configuring.md#tasks)
Availability: [`exclude`](configuring.md#exclude),
[`postrender_tasks`](configuring.md#postrender_tasks), [`tasks`](configuring.md#tasks)

### `_update_stage`

The current update stage, indicating which rendering context is active.

**Values:**

- `"current"` - Rendering to the actual destination (always during copy operations)
- `"previous"` - Rendering the old template version to temporary directory (update
only)
- `"new"` - Rendering the new template version to temporary directory (update only)

Available as `UPDATE_STAGE` environment variable.

Availability: [`postrender_tasks`](configuring.md#postrender_tasks),
[`tasks`](configuring.md#tasks)

!!! example

```yaml title="copier.yml"
_postrender_tasks:
# Run expensive operation only on actual destination
- command: "python refactor.py"
when: "{{ _update_stage == 'current' }}"
```

## Variables (context-specific)

Expand Down
Loading