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
18 changes: 16 additions & 2 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,23 @@ configure_repo_settings:


# TASKS --------------------------------
#
# There is deliberately no `chmod +x` task here. It used to be
# `chmod +x scripts/*.sh scripts/*.py`, which flipped the mode of *every* file
# a downstream project keeps in `scripts/` — including its own shebang-less
# ones, which then failed ruff's `EXE002` ("executable but no shebang") with no
# `.rej` and no warning. The obvious recovery (`chmod 644 scripts/*.py`) then
# trips `EXE001` on the legitimately-executable ones (DOT-628).
#
# It was never needed. Copier's own `_render_file` chmods each rendered file to
# the template's mode, preferring the template's *git-index* exec bit over
# `stat().st_mode` so it survives filesystems that don't carry the bit
# (Windows), then syncs the destination's git index to match. That runs on
# `copy` and `update` alike, and only for files copier actually renders — so
# `scripts/check-template-update.sh` (committed 100755) arrives executable,
# and files the template does not own are never touched. Verified against
# copier 9.17.1.
_tasks:
# Always run
- "chmod +x scripts/*.sh scripts/*.py 2>/dev/null || true"
# Run `uv sync` in copier's temp render dirs (`old_copy`/`new_copy`) AND on
# initial `copy`, but NOT in the real destination during update. Why this
# inverted guard (`[ ! -d .git ]`) instead of the more obvious "only sync in
Expand Down
53 changes: 53 additions & 0 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pathlib
import re
import shutil
import stat
import subprocess
import tomllib
from typing import TYPE_CHECKING, ClassVar
Expand Down Expand Up @@ -1430,6 +1431,58 @@ def test_uv_sync_task_runs_in_copier_temp_render_dirs(self) -> None:
"`[ ! -d .git ]` instead (see DOT-587 / DOT-588)."
)

def test_tasks_do_not_chmod_downstream_scripts(self) -> None:
"""`_tasks` must not blanket-chmod `scripts/` (DOT-628).

The template owns exactly one file under `scripts/`. A downstream project keeps its
own one-off scripts in the same directory, and the template has no business changing
their modes. `chmod +x scripts/*.sh scripts/*.py` did exactly that: every shebang-less
`.py` a project kept there went 644 -> 755 on update, and ruff's `EXE002` turned a
lint-clean repo red with no `.rej` file to explain it.

Nothing replaces the task, because nothing needs to: copier chmods each rendered file
to the template's git-index mode itself (`_render_file`), on copy and update alike.
See `test_shipped_script_is_rendered_executable` for the other half of this pair.
"""
copier_yml = pathlib.Path(__file__).resolve().parent.parent / "copier.yml"
tasks = yaml.safe_load(copier_yml.read_text())["_tasks"]

# Parse the YAML rather than grepping lines. A copier task is either a bare command
# string or a mapping with `command` (plus `when`), and either form can put the
# command on a folded continuation line — `- command: >-` with the body indented
# underneath. A line-oriented filter sees no `chmod` on the `- ` line and passes
# while copier happily runs the task.
commands = [task if isinstance(task, str) else task.get("command", "") for task in tasks]
offenders = [command for command in commands if "chmod" in command]

assert not offenders, (
"copier.yml _tasks contains a chmod task:\n"
+ "\n".join(offenders)
+ "\n\nThe template must not change modes of files it does not own — a glob over "
"`scripts/` catches the downstream project's own scripts and breaks ruff EXE002 "
"(DOT-628). Copier already propagates the template's exec bits; if a newly "
"shipped script needs +x, commit it 100755 instead."
)

def test_shipped_script_is_rendered_executable(self, copier_defaults: dict, project_factory) -> None:
"""The one script the template ships must arrive executable, with no chmod task.

This is the load-bearing half of DOT-628's fix: dropping the chmod task is only safe
because copier propagates the template's own mode. If `check-template-update.sh` were
ever committed 100644, the generated project's `check-shebang-scripts-are-executable`
hook would fail on the very first commit — so pin the rendered mode, not the source's.
"""
project = project_factory(copier_defaults)

script = project / "scripts" / "check-template-update.sh"
assert script.stat().st_mode & 0o111, (
f"{script.name} rendered non-executable ({stat.filemode(script.stat().st_mode)}). "
"It has a shebang, so the generated project's `check-shebang-scripts-are-executable` "
"hook will block every commit. Commit `project/scripts/check-template-update.sh` "
"with mode 100755 (`git update-index --chmod=+x`) — do not add a chmod task, which "
"would re-introduce DOT-628."
)

def test_update_banner_does_not_claim_deps_synced(self) -> None:
"""The `🎉 Template updated!` banner must not claim "Dependencies synced" (DOT-587 fix follow-up).

Expand Down
Loading