diff --git a/copier/_main.py b/copier/_main.py index 57a029438..1185ed4a6 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -510,6 +510,7 @@ def _render_allowed( is_dir: bool = False, is_symlink: bool = False, expected_contents: bytes | Path = b"", + expected_mode: int | None = None, ) -> bool: """Determine if a file or directory can be rendered. @@ -523,6 +524,12 @@ def _render_allowed( expected_contents: Used to compare existing file contents with them. Allows to know if rendering is needed. + expected_mode: + Used to compare the existing file's mode bits with the + template's, so a mode-only change between template versions + is not misclassified as "identical". Only the executable bits + (``0o111``) are compared, since those are the only mode bits + tracked by git. """ assert not dst_relpath.is_absolute() assert not expected_contents or not is_dir, "Dirs cannot have expected content" @@ -549,8 +556,18 @@ def _render_allowed( raise except IsADirectoryError: assert is_dir + mode_matches = True + if expected_mode is not None and not previous_is_symlink and not is_dir: + try: + dst_exec_bits = dst_abspath.stat().st_mode & 0o111 + except (FileNotFoundError, PermissionError): + dst_exec_bits = None + else: + mode_matches = dst_exec_bits == (expected_mode & 0o111) if is_dir or ( - previous_content == expected_contents and previous_is_symlink == is_symlink + previous_content == expected_contents + and previous_is_symlink == is_symlink + and mode_matches ): printf( "identical", @@ -825,8 +842,30 @@ def _render_file( # noqa: C901 else: new_content = src_abspath.read_bytes() dst_abspath = self.subproject.local_abspath / dst_relpath - src_mode = src_abspath.stat().st_mode - if not self._render_allowed(dst_relpath, expected_contents=new_content): + # Prefer the template's git-index mode over ``stat().st_mode`` so + # that executable bits committed by the template author are + # honored even on filesystems that don't represent them on disk + # (notably Windows). ``stat().st_mode`` is used for + # non-executable-bit flags (user/group/world read/write, etc.) + # and as a fallback when the file isn't tracked in the + # template's git index — e.g. local directory templates without + # a git repo, or untracked files. + stat_mode = src_abspath.stat().st_mode + git_mode = self.template.git_index_modes.get( + PurePosixPath(src_relpath.as_posix()) + ) + if git_mode is None: + src_mode = stat_mode + else: + # Merge the git-tracked executable bits with the filesystem's + # non-exec bits so we don't lose read/write flags that matter + # for the destination chmod. + src_mode = (stat_mode & ~0o111) | (git_mode & 0o111) + if not self._render_allowed( + dst_relpath, + expected_contents=new_content, + expected_mode=src_mode, + ): return if not self.pretend: dst_abspath.parent.mkdir(parents=True, exist_ok=True) @@ -846,6 +885,93 @@ def _render_file( # noqa: C901 f"{stat.filemode(dst_mode)} to {stat.filemode(src_mode)}", stacklevel=2, ) + self._sync_git_index_executable_bit(dst_relpath, src_mode) + + def _sync_git_index_executable_bit(self, dst_relpath: Path, src_mode: int) -> None: + """Propagate executable-bit changes to the destination's git index. + + Only needed when ``core.fileMode`` is ``false`` (the Windows default + and a common opt-out elsewhere): in that case git ignores on-disk + mode bits, so the ``chmod`` performed by :meth:`_render_file` is + invisible to git, and the executable bit would be silently lost on + the user's next commit. To keep the destination's index in sync + with the template, we explicitly rewrite the entry's mode via + ``git update-index --cacheinfo``. + + When ``core.fileMode`` is ``true`` (or unset, the unix default), + this method is a no-op: git already picks up the on-disk ``chmod`` + as an *unstaged* modification, which matches copier's normal + behavior of leaving rendered changes unstaged for user review. + + Also a no-op when the destination is not in a git repository, when + the file is not yet tracked (the user's eventual ``git add`` will + record the on-disk mode where the platform allows it), or when git + is unavailable. All git failures are swallowed so that copying or + updating cannot be broken by an unrelated git problem. + + .. note:: + + ``git update-index --chmod=±x`` cannot be used here even + though it looks simpler: it has a side effect of also + re-staging the current working-tree content as the blob (it + implicitly refreshes the index entry). During ``copier + update`` the working tree has already been overwritten with + the new template content at the time this method runs, so + ``--chmod`` would stomp the downstream-edited blob that the + update flow needs to reconstruct merge conflicts. + ``--cacheinfo`` rewrites the mode on the *existing* blob + SHA only, leaving the rest of the entry (and therefore the + conflict-reconstruction flow) untouched. + """ + subproject_root = self.subproject.local_abspath + git = get_git(context_dir=subproject_root) + try: + # ``--type=bool`` normalizes truthy/falsy spellings to + # ``true``/``false``. Exits 1 if the key is unset. + file_mode_setting = git( + "config", "--type=bool", "--get", "core.fileMode" + ).strip() + except ProcessExecutionError: + # Not a git repo, or ``core.fileMode`` is unset — unix default + # is ``true``, so plain ``chmod`` is enough; nothing to do. + return + if file_mode_setting != "false": + # git will pick up the plain on-disk ``chmod`` from + # :meth:`_render_file`; no index manipulation needed. + return + try: + # TODO: simplify with ``--format %(objectmode)`` once the + # minimum git version is raised to 2.38+ (--format cannot be + # combined with --stage; see git-ls-files(1)). + result = git("ls-files", "--stage", "--", str(dst_relpath)).strip() + except ProcessExecutionError: + # git can't read the index — fall back to a silent no-op. + return + if not result: + # File is not tracked yet; nothing to update. + return + # Format: " \t" + meta = result.split("\t", 1)[0].split() + current_index_mode = int(meta[0], 8) + current_index_sha = meta[1] + desired_executable = bool(src_mode & 0o111) + current_executable = bool(current_index_mode & 0o111) + if desired_executable == current_executable: + return + new_mode = "100755" if desired_executable else "100644" + try: + # ``--cacheinfo`` rewrites the entry's mode on the *existing* + # blob SHA. Unlike ``--chmod``, it does NOT re-read the + # working tree or restage its content. + git( + "update-index", + "--cacheinfo", + f"{new_mode},{current_index_sha},{dst_relpath}", + ) + except (OSError, ProcessExecutionError): + # git not installed, or some other unrelated git failure + # — silently fall back so we never break the render path. + pass def _render_symlink(self, src_relpath: Path, dst_relpath: Path) -> None: """Render one symlink. diff --git a/copier/_template.py b/copier/_template.py index 7650e6662..ce62d297e 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -19,6 +19,7 @@ import yaml from funcy import lflatten from packaging.version import Version, parse +from plumbum.commands.processes import ProcessExecutionError from plumbum.machines import local from pydantic.dataclasses import dataclass @@ -579,6 +580,46 @@ def local_abspath(self) -> Path: result = result.resolve() return result + @cached_property + def git_index_modes(self) -> Mapping[PurePosixPath, int]: + """Read file modes from the template's git index. + + This returns the mode bits for every tracked file as recorded in + git's index, keyed by POSIX path relative to + :attr:`local_abspath`. Git always records executable-bit + information in the index (as mode ``100755``/``100644``), even + on filesystems where the bit can't be represented on disk — most + notably Windows, where ``os.stat().st_mode`` never reports + ``S_IXUSR``/``S_IXGRP``/``S_IXOTH`` for regular files. + + Callers that want to know the template's *intended* file mode + (as committed by the template author) should consult this + mapping before falling back to ``Path.stat().st_mode``. + + Returns an empty mapping when the template is not a git + checkout, when git is unavailable, or when git fails for any + other reason — callers must be ready to fall back. + """ + if self.vcs != "git": + return {} + try: + git = get_git(context_dir=self.local_abspath) + # TODO: simplify with ``--format`` once the minimum git + # version is raised to 2.38+ (--format cannot be combined + # with --stage; see git-ls-files(1)). + raw = git("ls-files", "--stage").strip() + except (OSError, ProcessExecutionError): + return {} + modes: dict[PurePosixPath, int] = {} + for line in raw.splitlines(): + # Format: " \t" + meta, _, path = line.partition("\t") + if not path: + continue + mode_str, _, _ = meta.partition(" ") + modes[PurePosixPath(path)] = int(mode_str, 8) + return modes + @cached_property def url_expanded(self) -> str: """Get usable URL. diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index e80b515ca..0eb7b3084 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -2225,3 +2225,313 @@ def test_render_copier_conf_as_json_without_circular_reference( run_update(str(dst), overwrite=True, unsafe=True) assert (dst / "copier_conf.json").exists() + + +@pytest.mark.skipif( + condition=platform.system() == "Windows", + reason="Windows does not have UNIX-like permissions", +) +@pytest.mark.parametrize("file_mode", [True, False]) +def test_update_propagates_executable_bit_addition( + tmp_path_factory: pytest.TempPathFactory, + file_mode: bool, +) -> None: + """A template that gains ``+x`` between versions (without changing the + file's content) must propagate that bit to existing destinations on + ``copier update``. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # Template v1: launcher.sh is NOT executable. + with local.cwd(src): + build_file_tree( + { + "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", + "launcher.sh": "#!/bin/sh\necho hi\n", + } + ) + git_save(tag="v1") + + run_copy(str(src), dst, defaults=True, overwrite=True) + with local.cwd(dst): + git("init") + git("config", "core.fileMode", str(file_mode).lower()) + git("add", "-A") + git("commit", "-m", "init") + assert (dst / "launcher.sh").stat().st_mode & 0o111 == 0 + with local.cwd(dst): + # TODO: simplify with ``--format %(objectmode)`` once the minimum + # git version is raised to 2.38+ (--format cannot be combined with + # --stage; see git-ls-files(1)). + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + assert mode == "100644" + + # Template v2: launcher.sh gains +x with no content change. + with local.cwd(src): + Path("launcher.sh").chmod( + Path("launcher.sh").stat().st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH + ) + git("update-index", "--chmod=+x", "launcher.sh") + git("commit", "-am", "make executable") + git("tag", "v2") + + run_update(str(dst), defaults=True, overwrite=True) + + # On disk, the bit must be set after the update. + assert (dst / "launcher.sh").stat().st_mode & 0o111 != 0 + with local.cwd(dst): + # TODO: simplify with ``--format %(objectmode)`` once the minimum + # git version is raised to 2.38+ (--format cannot be combined with + # --stage; see git-ls-files(1)). + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + # Preserve the leading space — porcelain columns are index (XY) and + # worktree, and a leading space distinguishes " M" (unstaged) from + # "M " (staged). ``.strip()`` would swallow the space. + status = git("status", "--porcelain", "--", "launcher.sh").rstrip("\n") + if file_mode: + # With ``core.fileMode=true``, copier must NOT stage the index mode + # change — git picks up the on-disk ``chmod`` as an unstaged + # modification, matching copier's normal behavior of leaving + # rendered changes unstaged for user review. Guards against + # auto-staging regression. + assert mode == "100644" + assert status.startswith(" M"), f"expected unstaged change, got {status!r}" + # A subsequent ``git add`` records 100755 via git's normal flow. + with local.cwd(dst): + git("add", "--", "launcher.sh") + mode_after_add = ( + git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + ) + assert mode_after_add == "100755" + else: + # With ``core.fileMode=false``, git would ignore the on-disk chmod, + # so copier must explicitly call ``git update-index --chmod=+x`` + # to record the new mode in the index. + assert mode == "100755" + + +@pytest.mark.skipif( + condition=platform.system() == "Windows", + reason="Windows does not have UNIX-like permissions", +) +@pytest.mark.parametrize("file_mode", [True, False]) +def test_update_propagates_executable_bit_removal( + tmp_path_factory: pytest.TempPathFactory, + file_mode: bool, +) -> None: + """When a template drops the executable bit between versions, the + destination's on-disk mode and git index must lose ``+x`` too. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", + "launcher.sh": "#!/bin/sh\necho hi\n", + } + ) + Path("launcher.sh").chmod( + Path("launcher.sh").stat().st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH + ) + git_save(tag="v1") + + run_copy(str(src), dst, defaults=True, overwrite=True) + with local.cwd(dst): + git("init") + git("config", "core.fileMode", str(file_mode).lower()) + git("add", "-A") + if not file_mode: + # With core.fileMode=false, git add ignores on-disk exec bits, + # so we must set the index mode explicitly to start at 100755. + git("update-index", "--chmod=+x", "--", "launcher.sh") + git("commit", "-m", "init") + # TODO: simplify with ``--format %(objectmode)`` once the minimum + # git version is raised to 2.38+ (--format cannot be combined with + # --stage; see git-ls-files(1)). + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + assert mode == "100755" + + # Template v2: drop +x (no content change). + with local.cwd(src): + Path("launcher.sh").chmod( + Path("launcher.sh").stat().st_mode + & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + ) + git("update-index", "--chmod=-x", "launcher.sh") + git("commit", "-am", "drop executable") + git("tag", "v2") + + run_update(str(dst), defaults=True, overwrite=True) + + assert (dst / "launcher.sh").stat().st_mode & 0o111 == 0 + with local.cwd(dst): + # TODO: simplify with ``--format %(objectmode)`` once the minimum + # git version is raised to 2.38+ (--format cannot be combined with + # --stage; see git-ls-files(1)). + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + # Preserve the leading space — porcelain columns are index (XY) and + # worktree, and a leading space distinguishes " M" (unstaged) from + # "M " (staged). ``.strip()`` would swallow the space. + status = git("status", "--porcelain", "--", "launcher.sh").rstrip("\n") + if file_mode: + # Same rationale as the addition test: with ``core.fileMode=true`` + # the chmod should be visible to git as unstaged, and copier must + # not pre-stage it via ``git update-index --chmod``. + assert mode == "100755" + assert status.startswith(" M"), f"expected unstaged change, got {status!r}" + with local.cwd(dst): + git("add", "--", "launcher.sh") + mode_after_add = ( + git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + ) + assert mode_after_add == "100644" + else: + assert mode == "100644" + + +@pytest.mark.skipif( + condition=platform.system() != "Windows", + reason="Exercises the Windows code path where the filesystem does not " + "represent UNIX-like mode bits.", +) +def test_update_preserves_exec_bit_authored_on_unix( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """A Windows user running ``copier update`` on a template that was + authored on Linux/macOS must see executable-bit changes propagate + to the destination's git index. + + On Windows, git's default ``core.fileMode=false`` means the + executable bit is never represented on disk, only in the index. + Copier cannot rely on ``os.stat().st_mode`` to read the template's + intended mode — it must consult the template's git index instead. + This test sets up the template using only ``git update-index + --chmod`` (no on-disk ``chmod``, which would be a no-op on Windows + anyway) and verifies the bit survives end-to-end into the + destination's index. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # Template v1: launcher.sh is NOT executable. Set up the git index + # entry without touching on-disk mode bits — mimics how a + # Linux-authored commit appears to a Windows clone. + with local.cwd(src): + build_file_tree( + { + "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", + "launcher.sh": "#!/bin/sh\necho hi\n", + } + ) + git_save(tag="v1") + + run_copy(str(src), dst, defaults=True, overwrite=True) + with local.cwd(dst): + git("init") + # Windows default — explicit here to avoid relying on it. + git("config", "core.fileMode", "false") + git("add", "-A") + git("commit", "-m", "init") + # TODO: simplify with ``--format %(objectmode)`` once the minimum + # git version is raised to 2.38+ (--format cannot be combined with + # --stage; see git-ls-files(1)). + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + assert mode == "100644" + + # Template v2: launcher.sh gains +x via ``git update-index --chmod`` + # only. No on-disk ``chmod`` — this is the scenario where the + # template author committed ``100755`` from Linux, and the + # destination is a filesystem that does not carry exec bits. + with local.cwd(src): + git("update-index", "--chmod=+x", "launcher.sh") + git("commit", "-am", "make executable") + git("tag", "v2") + + run_update(str(dst), defaults=True, overwrite=True) + + # The destination's git index must record 100755, even though + # ``src_abspath.stat().st_mode`` on Windows would not show the exec + # bit. This only works because copier reads the template's intended + # mode from its git index via ``Template.git_index_modes``. + with local.cwd(dst): + mode = git("ls-files", "--stage", "--", "launcher.sh").strip().split()[0] + assert mode == "100755" + + +@pytest.mark.skipif( + condition=platform.system() == "Windows", + reason="Windows does not have UNIX-like permissions", +) +@pytest.mark.parametrize("file_mode", [True, False]) +def test_update_with_exec_bit_change_and_merge_conflict( + tmp_path_factory: pytest.TempPathFactory, + file_mode: bool, +) -> None: + """``copier update`` on a file that gains ``+x`` AND has a + conflicting local edit must register a proper merge conflict in the + index — the executable-bit plumbing must not silently collapse the + conflict stages. + + Regression guard: ``git update-index --chmod`` on a file with + stages 1/2/3 collapses them into stage 0, destroying the conflict. + Copier only avoids this because ``_sync_git_index_executable_bit`` + runs in ``_render_file`` *before* the conflict registration in + ``_apply_update``. This test locks in that ordering guarantee + against future refactors. + """ + filename = "launcher.sh" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # Template v1: non-exec launcher.sh with one line of content. + build_file_tree( + { + (src / filename): "upstream version 1", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{_copier_answers|to_nice_yaml}}" + ), + } + ) + with local.cwd(src): + git_init("hello template") + git("tag", "v1") + + run_copy(str(src), dst, defaults=True, overwrite=True) + with local.cwd(dst): + git_init("hello project") + if not file_mode: + git("config", "core.fileMode", "false") + # Downstream edit that conflicts with template v2. + Path(filename).write_text("upstream version 1 + downstream") + git("commit", "-am", "downstream edit") + + # Template v2: gain +x AND change the same line (conflicts with the + # downstream edit above). + with local.cwd(src): + Path(filename).write_text("upstream version 2") + Path(filename).chmod( + Path(filename).stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + git("update-index", "--chmod=+x", filename) + git("add", ".", "-A") + git("commit", "-m", "make executable and update content") + git("tag", "v2") + + run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline") + + # The conflict must still be registered. If our ``update-index + # --chmod`` were called after stages 1/2/3 were registered, it + # would collapse them into stage 0 and the conflict would silently + # disappear. + assert "<<<<<<< before updating" in (dst / filename).read_text() + with local.cwd(dst): + lines = git("status", "--porcelain=v1").strip().splitlines() + assert any( + line.startswith("UU") and normalize_git_path(line[3:]) == filename + for line in lines + ), f"expected UU conflict for {filename}, got {lines!r}"