From 891bd46bfeb0fad39706fe095c2f0cf5d622766f Mon Sep 17 00:00:00 2001 From: Stanislav Khlud Date: Wed, 17 Dec 2025 12:04:37 +0700 Subject: [PATCH 1/4] fix: insure git hooks are ignored on checkout during resolution conflicts --- copier/_main.py | 10 ++- tests/test_updatediff.py | 131 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index 96f5fac87..d59414221 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -1335,7 +1335,15 @@ def _apply_update(self) -> None: # noqa: C901 # Remove ".rej" suffix fname = fname[:-4] # Undo possible non-rejected chunks - git("checkout", "--", fname) + git( + # Ignore hooks to avoid errors from them or + # issues when .pre-commit-config.yaml is changed + "-c", + "core.hooksPath=/dev/null", + "checkout", + "--", + fname, + ) # 3-way-merge the file directly git( "merge-file", diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index 79ce29f05..961795e71 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -321,7 +321,7 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non _templates_suffix: {SUFFIX_TMPL} _tasks: - git init - - pre-commit install -t pre-commit -t commit-msg + - pre-commit install - pre-commit run -a || true what: grog """ @@ -333,6 +333,10 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non ), ".pre-commit-config.yaml": ( r""" + default_install_hook_types: [ + pre-commit, + commit-msg, + ] repos: - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.0.4 @@ -480,6 +484,131 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non assert Path(f"{life}.rej").is_file() +# Checkout test_commit_hooks_respected +# FIXME Some generous Windows power user please fix this test! +@pytest.mark.xfail( + condition=platform.system() == "Windows", reason="Git broken on Windows?" +) +@pytest.mark.impure +def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> None: + """Ignore post-checkout hook when conflicts are encountered and .pre-commit-config.yaml is updated.""" + # Prepare source template v1 + src, dst1 = map(tmp_path_factory.mktemp, ("src", "dst1")) + with local.cwd(src): + build_file_tree( + { + "copier.yml": ( + f""" + _envops: {BRACKET_ENVOPS_JSON} + _templates_suffix: {SUFFIX_TMPL} + _tasks: + - git init + - pre-commit install + - pre-commit run -a || true + """ + ), + "[[ _copier_conf.answers_file ]].tmpl": ( + """ + [[ _copier_answers|to_nice_yaml ]] + """ + ), + "test.txt": "This is a file", + ".pre-commit-config.yaml": ( + r""" + default_install_hook_types: [ + pre-commit, + pre-push, + post-checkout, + post-merge, + post-rewrite, + ] + repos: + - repo: local + hooks: + - id: echo + name: echo V1 + entry: exit 0 + language: system + pass_filenames: false + types: [ file ] + stages: [ + post-checkout, + post-merge, + post-rewrite, + ] + """ + ), + } + ) + git("init") + git("add", ".") + git("commit", "-m", "feat: commit 1") + git("tag", "v1") + # Copy source template + run_copy( + src_path=str(src), + dst_path=dst1, + defaults=True, + overwrite=False, + unsafe=True, + ) + with local.cwd(dst1): + git("add", ".") + # Commit initial copy + git("commit", "-am", "feat: copied v1") + # Introduce conflict + Path(f"{dst1}/test.txt").open(mode="w").write("This is a conflicting change") + git("add", ".") + git("commit", "-am", "feat: edit test.txt") + # Evolve source template to v2 + with local.cwd(src): + build_file_tree( + { + ".pre-commit-config.yaml": ( + r""" + default_install_hook_types: [ + pre-commit, + pre-push, + post-checkout, + post-merge, + post-rewrite, + ] + repos: + - repo: local + hooks: + - id: echo + name: echo V2 + entry: exit 0 + language: system + pass_filenames: false + types: [ file ] + stages: [ + post-checkout, + post-merge, + post-rewrite, + ] + """ + ), + "test.txt": "This is a edited file in v2", + } + ) + git("init") + git("add", ".") + git("commit", "-m", "feat: update .pre-commit-config.yaml") + git("tag", "v2") + # Update subproject to v2 + # No errors should be raised due to post-checkout hook when + # encountering conflicts and .pre-commit-config.yaml is updated + run_update( + dst_path=dst1, + defaults=True, + overwrite=True, + unsafe=True, + skip_tasks=True, + conflict="inline", + ) + + def test_update_from_tagged_to_head(tmp_path_factory: pytest.TempPathFactory) -> None: src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) # Build a template From 7de7d61a0e4501d2aec45599b7cc5a1e1a38c08c Mon Sep 17 00:00:00 2001 From: Stanislav Khlud Date: Fri, 16 Jan 2026 17:19:12 +0700 Subject: [PATCH 2/4] Code review fixes --- copier/_main.py | 2 +- tests/test_updatediff.py | 61 +++++++--------------------------------- 2 files changed, 11 insertions(+), 52 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index d59414221..a17c63c39 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -1339,7 +1339,7 @@ def _apply_update(self) -> None: # noqa: C901 # Ignore hooks to avoid errors from them or # issues when .pre-commit-config.yaml is changed "-c", - "core.hooksPath=/dev/null", + f"core.hooksPath={os.devnull}", "checkout", "--", fname, diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index 961795e71..d0c47d165 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -491,7 +491,7 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non ) @pytest.mark.impure def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> None: - """Ignore post-checkout hook when conflicts are encountered and .pre-commit-config.yaml is updated.""" + """Ignore post-checkout hook when conflicts are encountered.""" # Prepare source template v1 src, dst1 = map(tmp_path_factory.mktemp, ("src", "dst1")) with local.cwd(src): @@ -503,8 +503,8 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> _templates_suffix: {SUFFIX_TMPL} _tasks: - git init - - pre-commit install - - pre-commit run -a || true + - git config --local core.hooksPath hooks/ + - chmod +x hooks/post-checkout """ ), "[[ _copier_conf.answers_file ]].tmpl": ( @@ -513,29 +513,9 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> """ ), "test.txt": "This is a file", - ".pre-commit-config.yaml": ( + "hooks/post-checkout": ( r""" - default_install_hook_types: [ - pre-commit, - pre-push, - post-checkout, - post-merge, - post-rewrite, - ] - repos: - - repo: local - hooks: - - id: echo - name: echo V1 - entry: exit 0 - language: system - pass_filenames: false - types: [ file ] - stages: [ - post-checkout, - post-merge, - post-rewrite, - ] + echo "Post-checkout hook executed" """ ), } @@ -561,44 +541,23 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> git("add", ".") git("commit", "-am", "feat: edit test.txt") # Evolve source template to v2 + # No errors should be raised due to post-checkout hook with local.cwd(src): build_file_tree( { - ".pre-commit-config.yaml": ( + "test.txt": "This is a edited file in v2", + "hooks/post-checkout": ( r""" - default_install_hook_types: [ - pre-commit, - pre-push, - post-checkout, - post-merge, - post-rewrite, - ] - repos: - - repo: local - hooks: - - id: echo - name: echo V2 - entry: exit 0 - language: system - pass_filenames: false - types: [ file ] - stages: [ - post-checkout, - post-merge, - post-rewrite, - ] + exit 1 """ ), - "test.txt": "This is a edited file in v2", } ) git("init") git("add", ".") - git("commit", "-m", "feat: update .pre-commit-config.yaml") + git("commit", "-m", "feat: Update post-checkout") git("tag", "v2") # Update subproject to v2 - # No errors should be raised due to post-checkout hook when - # encountering conflicts and .pre-commit-config.yaml is updated run_update( dst_path=dst1, defaults=True, From 49017dc46068641c24e5461b71855c5fbdf1b215 Mon Sep 17 00:00:00 2001 From: Stanislav Khlud Date: Tue, 20 Jan 2026 10:56:44 +0700 Subject: [PATCH 3/4] Code review fixes --- tests/test_updatediff.py | 42 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index d0c47d165..e197a2fa1 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -1,6 +1,7 @@ from __future__ import annotations import platform +import stat from pathlib import Path from shutil import rmtree from textwrap import dedent @@ -321,7 +322,7 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non _templates_suffix: {SUFFIX_TMPL} _tasks: - git init - - pre-commit install + - pre-commit install -t pre-commit -t commit-msg - pre-commit run -a || true what: grog """ @@ -333,10 +334,6 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non ), ".pre-commit-config.yaml": ( r""" - default_install_hook_types: [ - pre-commit, - commit-msg, - ] repos: - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.0.4 @@ -484,16 +481,11 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non assert Path(f"{life}.rej").is_file() -# Checkout test_commit_hooks_respected -# FIXME Some generous Windows power user please fix this test! -@pytest.mark.xfail( - condition=platform.system() == "Windows", reason="Git broken on Windows?" -) @pytest.mark.impure def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> None: """Ignore post-checkout hook when conflicts are encountered.""" # Prepare source template v1 - src, dst1 = map(tmp_path_factory.mktemp, ("src", "dst1")) + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) with local.cwd(src): build_file_tree( { @@ -503,8 +495,6 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> _templates_suffix: {SUFFIX_TMPL} _tasks: - git init - - git config --local core.hooksPath hooks/ - - chmod +x hooks/post-checkout """ ), "[[ _copier_conf.answers_file ]].tmpl": ( @@ -513,11 +503,6 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> """ ), "test.txt": "This is a file", - "hooks/post-checkout": ( - r""" - echo "Post-checkout hook executed" - """ - ), } ) git("init") @@ -527,30 +512,28 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> # Copy source template run_copy( src_path=str(src), - dst_path=dst1, + dst_path=dst, defaults=True, overwrite=False, unsafe=True, ) - with local.cwd(dst1): + with local.cwd(dst): git("add", ".") # Commit initial copy git("commit", "-am", "feat: copied v1") # Introduce conflict - Path(f"{dst1}/test.txt").open(mode="w").write("This is a conflicting change") + Path(dst / "test.txt").open(mode="w").write("This is a conflicting change") git("add", ".") git("commit", "-am", "feat: edit test.txt") + # Add post-checkout hook that fails + hook_file = dst / ".git" / "hooks" / "post-checkout" + hook_file.write_text("exit 1") + hook_file.chmod(hook_file.stat().st_mode | stat.S_IXUSR) # Evolve source template to v2 - # No errors should be raised due to post-checkout hook with local.cwd(src): build_file_tree( { - "test.txt": "This is a edited file in v2", - "hooks/post-checkout": ( - r""" - exit 1 - """ - ), + "test.txt": "This is an edited file in v2", } ) git("init") @@ -558,8 +541,9 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> git("commit", "-m", "feat: Update post-checkout") git("tag", "v2") # Update subproject to v2 + # No errors should be raised due to post-checkout hook run_update( - dst_path=dst1, + dst_path=dst, defaults=True, overwrite=True, unsafe=True, From c847a56c515a1bce500e955b30d3071439bc6d37 Mon Sep 17 00:00:00 2001 From: Stanislav Khlud Date: Thu, 22 Jan 2026 10:09:07 +0700 Subject: [PATCH 4/4] Code review fixes --- tests/test_updatediff.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index e197a2fa1..7c01c207a 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -481,7 +481,6 @@ def test_commit_hooks_respected(tmp_path_factory: pytest.TempPathFactory) -> Non assert Path(f"{life}.rej").is_file() -@pytest.mark.impure def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> None: """Ignore post-checkout hook when conflicts are encountered.""" # Prepare source template v1 @@ -493,8 +492,6 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> f""" _envops: {BRACKET_ENVOPS_JSON} _templates_suffix: {SUFFIX_TMPL} - _tasks: - - git init """ ), "[[ _copier_conf.answers_file ]].tmpl": ( @@ -513,20 +510,18 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> run_copy( src_path=str(src), dst_path=dst, - defaults=True, - overwrite=False, - unsafe=True, ) with local.cwd(dst): + git("init") git("add", ".") # Commit initial copy git("commit", "-am", "feat: copied v1") # Introduce conflict - Path(dst / "test.txt").open(mode="w").write("This is a conflicting change") + Path("test.txt").write_text("This is a conflicting change") git("add", ".") git("commit", "-am", "feat: edit test.txt") # Add post-checkout hook that fails - hook_file = dst / ".git" / "hooks" / "post-checkout" + hook_file = Path(".git") / "hooks" / "post-checkout" hook_file.write_text("exit 1") hook_file.chmod(hook_file.stat().st_mode | stat.S_IXUSR) # Evolve source template to v2 @@ -536,7 +531,6 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> "test.txt": "This is an edited file in v2", } ) - git("init") git("add", ".") git("commit", "-m", "feat: Update post-checkout") git("tag", "v2") @@ -544,11 +538,7 @@ def test_post_checkout_hook_ignored(tmp_path_factory: pytest.TempPathFactory) -> # No errors should be raised due to post-checkout hook run_update( dst_path=dst, - defaults=True, overwrite=True, - unsafe=True, - skip_tasks=True, - conflict="inline", )