Skip to content

fix: propagate executable-bit changes on copier update#2605

Open
willemkokke wants to merge 1 commit intocopier-org:masterfrom
willemkokke:fix/preserve-mode-bits-on-update
Open

fix: propagate executable-bit changes on copier update#2605
willemkokke wants to merge 1 commit intocopier-org:masterfrom
willemkokke:fix/preserve-mode-bits-on-update

Conversation

@willemkokke
Copy link
Copy Markdown

@willemkokke willemkokke commented Apr 10, 2026

Summary

Fixes #2604.

copier update did not reliably propagate template executable-bit (file mode) changes to destinations. Three distinct failure modes:

  1. Mode-only template change (no content change): _render_allowed classified the file as "identical" and _render_file short-circuited before reaching the chmod call — a silent no-op on every platform.
  2. Mode + content change on core.fileMode=false (Windows default): copier wrote the new content and chmodd the file on disk, but the destination's git index was never updated. Since git ignores on-disk mode bits when core.fileMode=false, the executable bit was silently lost on the user's next commit.
  3. Template authored on Linux/macOS, consumed on Windows: copier read the template's intended mode from Path.stat().st_mode, which on Windows never carries UNIX exec bits. Templates committed with 100755 on Linux were indistinguishable from 100644 when rendered on Windows, so the executable bit was lost end-to-end.

Changes

copier/_main.py:

  • _render_allowed: new expected_mode parameter. Compares only the executable bits (0o111) against the existing destination file so that a mode-only template change is no longer misclassified as "identical".
  • _render_file: now consults self.template.git_index_modes for exec-bit determination, merging the git-tracked exec bits with the filesystem's non-exec flags (fall back to pure stat() for non-git templates and untracked files). This makes the fix work even on filesystems that don't represent exec bits on disk.
  • _sync_git_index_executable_bit (new): runs only when core.fileMode=false. Uses git update-index --cacheinfo <mode>,<sha>,<path> (not --chmod), which rewrites only the mode on the existing blob SHA. --chmod looks simpler but has a hidden side effect of re-staging the current working-tree content as the new blob, which would stomp the downstream-edited blob that _apply_update needs to reconstruct merge conflicts. When core.fileMode=true the method is a no-op — git picks up the on-disk chmod naturally as an unstaged modification, matching copier's normal "leave changes unstaged for user review" behavior. All git failures are silently swallowed so the render path cannot be broken by unrelated git problems.

copier/_template.py:

  • New Template.git_index_modes cached property: reads file modes directly from the template clone's git index via a single git ls-files --stage call. Returns an empty mapping for non-git templates, silently falling back to stat() callers.

tests/test_updatediff.py — new regression tests:

Test What it covers
test_update_propagates_executable_bit_addition[True|False] Mode-only promotion (gain +x, no content change), parametrized over core.fileMode. The True case asserts the change is unstaged after update, guarding against auto-staging regression; the False case asserts the index records 100755.
test_update_propagates_executable_bit_removal[True|False] Symmetric: template drops +x, destination index/working tree must lose the exec bit.
test_update_preserves_exec_bit_authored_on_unix Windows-only end-to-end test. Sets up the template using only git update-index --chmod (no Path.chmod) and verifies the exec bit survives all the way into the destination's git index on core.fileMode=false — exercising the Template.git_index_modes code path that's load-bearing on Windows.
test_update_with_exec_bit_change_and_merge_conflict[True|False] Deliberately creates a merge conflict on a file that gains +x between template versions, parametrized over core.fileMode. Asserts the conflict is properly registered (UU, conflict markers, stages 1/2/3 present). Locks in the ordering guarantee that _sync_git_index_executable_bit runs in _render_file before the conflict registration in _apply_update, and that --cacheinfo does not corrupt the conflict stages.

Test plan

  • All new regression tests pass on macOS
  • test_update_preserves_exec_bit_authored_on_unix passes on a real Windows machine (first run — previously only reasoned about)
  • Full copier test suite on macOS: 1063 passed, 5 skipped, 8 xfailed, 1 xpassed
  • Full copier test suite on Windows: all exec-bit-related tests green. 5 pre-existing symlink failures in tests/test_symlinks.py and tests/test_dirty_local.py reproduce identically on master on the same machine (local environment issues, unrelated to this PR)
  • ruff check, ruff format, mypy all clean
  • Rebased onto latest master and re-verified

Copy link
Copy Markdown
Member

@sisp sisp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for discovering this bug and contributing a fix, @willemkokke! 🙇 I've left a couple of remarks.

@willemkokke
Copy link
Copy Markdown
Author

All very valid, thanks!

Will update my PR now

@willemkokke willemkokke force-pushed the fix/preserve-mode-bits-on-update branch from db63917 to c8d6108 Compare April 10, 2026 18:28
@willemkokke
Copy link
Copy Markdown
Author

Thanks for the thorough review, @sisp! I've addressed all your feedback. Here's a summary of the changes:

copier/_main.py

  • _render_allowed: simplified if dst_exec_bits is not None:else:
  • _sync_git_index_executable_bit: narrowed the outer try/except to wrap only the git update-index call instead of the entire method body

tests/test_updatediff.py

  • Parametrized over core.fileMode (True/False) instead of separate test functions — collapsed 3 tests into 2
  • Removed implementation-detail references from docstrings ("the fix", _render_allowed, "previous tests")
  • Replaced (src / "launcher.sh") with Path("launcher.sh") where already inside local.cwd(src)
  • Dropped the content change from the core.fileMode=false scenario (no longer needed since _render_allowed now detects mode-only changes)

Intentionally kept --stage + .split()[0] instead of switching to --format %(objectmode): the --format option for git ls-files requires Git 2.38+ (git-ls-files(1)), while copier documents Git 2.27+ as the minimum. I've added TODO comments in both the production code and tests to simplify this once the minimum git version is raised. Happy to switch if bumping the minimum to 2.38+ is acceptable.

@willemkokke willemkokke requested a review from sisp April 11, 2026 02:14
@sisp
Copy link
Copy Markdown
Member

sisp commented Apr 13, 2026

Thanks for updating the PR, @willemkokke! 👍

I noticed that running git update-index --chmod=±x <file> stages the file, whereas changing a file's mode with chmod +x <file> with core.fileMode=true lets Git detect the change without staging it. According to my research, there seems to be no way to avoid staging the file with git update-index --chmod=±x <file>, and a subsequent git restore --staged <file> undoes the file mode change. Do you agree? I also wonder if there are any side effects when the same file has a merge conflict on update. Would you mind checking this (perhaps a simple test case might help)?

@willemkokke
Copy link
Copy Markdown
Author

Thanks for raising this, @sisp! You're right on both counts — I did some investigation and here's what I found:

1. Auto-staging with git update-index --chmod

Confirmed: there's no way to update the index mode without also staging the change. The git plumbing simply doesn't distinguish between "change this field in the index" and "stage this field". So update-index --chmod always stages.

However, this side effect is only an issue when core.fileMode=true. In that case, the existing on-disk chmod in _render_file is already picked up by git as an unstaged modification — exactly matching copier's normal "leave things unstaged for user review" behavior. So the update-index call is redundant and introduces the auto-staging inconsistency.

When core.fileMode=false, update-index --chmod is the only way to record the mode in the index, and pre-staging is not a behavioral regression because git would have ignored plain chmod anyway.

Fix: skip update-index --chmod entirely when core.fileMode is not false. I'll push this shortly.

2. Merge conflict interaction

This is where it gets interesting. I reproduced the scenario in isolation and confirmed your suspicion was warranted — and worse than you might have thought. Running git update-index --chmod=±x on a file in stages 1/2/3 doesn't just stage the mode change: it silently collapses all stages into stage 0, using the current working-tree content (including conflict markers) as the blob. git status changes from UU to MM — the conflict indicator disappears. A subsequent git commit would commit conflict markers with no warning.

However, after tracing the update flow carefully, this cannot actually occur during copier update:

  • run_update refuses to run on a dirty working tree via is_dirty(), which uses git status --porcelain. Unmerged entries show up in porcelain output, so pre-existing merge conflicts are blocked with "Destination repository is dirty; cannot continue."
  • In _apply_update, conflicts created during the update are registered after _render_file runs. The sequence is:
    1. L1353 current_worker.run_copy()_render_file_sync_git_index_executable_bit. Index has only stage 0 here.
    2. L1457 git apply --reject creates .rej files.
    3. L1487 git merge-file writes inline conflict markers.
    4. L1553 git update-index --index-info registers stages 1/2/3.
  • The conflict registration at L1529 reads ls-files --stage for the current perms when constructing stages 1/2/3, so the exec bit we set in stage 0 propagates correctly through the conflict flow.

In other words, copier's clean-index guarantee + the order of operations in _apply_update means our update-index --chmod always runs on a conflict-free index. The catastrophic scenario I reproduced in isolation can't happen here.

Plan

  1. Skip update-index --chmod when core.fileMode is not false.
  2. Update the existing parametrized tests: the file_mode=True case should assert the change is unstaged after update (guards against regression into auto-staging).
  3. Add a new regression test that deliberately creates a merge conflict on a file gaining +x (parametrized over core.fileMode), asserting that the conflict is properly registered (UU, stages 1/2/3 present, exec bit propagated through) — to lock in the timing guarantee against future refactors.

Does this sound reasonable? Will push once you confirm the direction.

@sisp
Copy link
Copy Markdown
Member

sisp commented Apr 13, 2026

Sounds reasonable, let's go ahead as you suggested.

@willemkokke willemkokke force-pushed the fix/preserve-mode-bits-on-update branch from c8d6108 to 667845a Compare April 13, 2026 19:05
@willemkokke willemkokke marked this pull request as draft April 13, 2026 19:06
`copier update` did not reliably propagate template executable-bit
(file mode) changes to destinations:

1. **Mode-only template change**: `_render_allowed` classified the file
   as "identical" (content matched) and `_render_file` short-circuited
   before reaching the `chmod` call — a complete silent no-op.

2. **Mode + content change on `core.fileMode=false`** (Windows default):
   copier wrote the new content and `chmod`d the file on disk, but the
   destination's git index was never updated. Since git ignores on-disk
   mode bits when `core.fileMode=false`, the executable bit was silently
   lost on the user's next commit.

3. **Template authored on Linux/macOS, consumed on Windows**: copier
   read the template's intended mode from `Path.stat().st_mode`, which
   on Windows never carries UNIX exec bits. Templates committed with
   `100755` on Linux were indistinguishable from `100644` when rendered
   on Windows, so the executable bit was lost end-to-end.

This commit fixes all three:

- Thread an `expected_mode` parameter into `_render_allowed` so that a
  mode-only change (comparing only the executable bits, `0o111`) is no
  longer misclassified as "identical".

- Add a `Template.git_index_modes` cached property that reads file
  modes directly from the template clone's git index via
  `git ls-files --stage`. `_render_file` now prefers this intended
  mode over `stat().st_mode`, which makes the fix work even on
  filesystems that don't represent exec bits on disk.

- After the existing on-disk `chmod` in `_render_file`, call a new
  `_sync_git_index_executable_bit` helper that rewrites the
  destination's index entry when `core.fileMode=false`. It uses
  `git update-index --cacheinfo <mode>,<sha>,<path>` (not `--chmod`),
  which rewrites *only* the mode on the existing blob SHA. `--chmod`
  looks simpler but has a hidden side effect of re-staging the current
  working-tree content as the new blob, which would stomp the
  downstream-edited blob that `_apply_update` needs to reconstruct
  merge conflicts. When `core.fileMode=true` the method is a no-op —
  git picks up the on-disk `chmod` naturally as an unstaged
  modification, matching copier's normal "leave changes unstaged for
  user review" behavior.

Adds three new tests:

- `test_update_propagates_executable_bit_addition`: parametrized over
  `core.fileMode=[true, false]`. The `true` case asserts the change is
  unstaged after update (guards against auto-staging regression); the
  `false` case asserts the index records `100755`.
- `test_update_propagates_executable_bit_removal`: symmetric.
- `test_update_preserves_exec_bit_authored_on_unix`: Windows-only test
  that sets up the template with `git update-index --chmod` (no
  on-disk chmod, mimicking a clone from a Linux-authored commit) and
  verifies the exec bit propagates end-to-end on `core.fileMode=false`.
- `test_update_with_exec_bit_change_and_merge_conflict`: parametrized
  regression test that locks in the ordering guarantee: during
  `copier update`, `_sync_git_index_executable_bit` runs in
  `_render_file` *before* the conflict registration in `_apply_update`,
  and uses `--cacheinfo` so it does not corrupt the conflict stages.

Fixes copier-org#2604
@willemkokke willemkokke force-pushed the fix/preserve-mode-bits-on-update branch from 667845a to 6b53f4c Compare April 13, 2026 19:59
@willemkokke willemkokke marked this pull request as ready for review April 13, 2026 19:59
@willemkokke
Copy link
Copy Markdown
Author

Hey @sisp — update on this PR. The second round of feedback prompted a deeper investigation that uncovered a few things worth flagging, and the fix is now broader than originally planned. TL;DR: cross-platform support is in, conflict corruption is guarded against, and I verified on Windows.

What changed from the previous plan I outlined

1. git update-index --chmod has a hidden side effect — switched to --cacheinfo

While writing the merge-conflict regression test, the file_mode=False case failed in a way that didn't match my earlier reasoning. Digging in, I found that git update-index --chmod=±x does not just change the mode — it implicitly re-reads the working tree and re-stages the current file content as the new blob. Minimal repro:

$ echo "original" > file && git add file && git commit -qm init
$ echo "modified" > file
$ git ls-files --stage file
100644 4b48deed3a433909bfd6b6ab3d4b91348b6af464 0	file
$ git update-index --chmod=+x file
$ git ls-files --stage file
100755 2e0996000b7e9019eabcad29391bf0f5c7702f0b 0	file   ← new blob!

During copier update, _render_file writes the new template content to disk before _sync_git_index_executable_bit runs. --chmod was therefore stomping the downstream-edited blob that _apply_update's conflict-reconstruction flow (L1487-L1556) needs to derive stages 1/2/3. The merge conflict silently disappeared — exactly the kind of data-corruption scenario you'd originally flagged, just via a different mechanism than I'd predicted.

Fix: switched to git update-index --cacheinfo <mode>,<sha>,<path>, which rewrites only the mode on the existing blob SHA. Left a detailed code comment explaining why we don't use --chmod, because this is the kind of non-obvious trap a future refactor could regress into. Verified comma-form --cacheinfo has been available since Git ~1.9/2014, so it's well under the 2.27 minimum.

2. Only touch the index when core.fileMode=false (auto-staging side effect)

As planned in the previous comment. When core.fileMode=true, _sync_git_index_executable_bit is a no-op — git picks up the on-disk chmod naturally as an unstaged modification, matching copier's normal "leave rendered changes unstaged for user review" behavior.

3. Scope expanded to fix Windows end-to-end (new)

Looking at the proposed "update existing tests to run on both core.fileMode=true/false" suggestion, I realized copier had a deeper, unaddressed issue on Windows that our fix didn't touch:

  1. Template authored on Linux/macOS commits launcher.sh with index mode 100755.
  2. Windows user clones → on-disk file has no exec bit (Windows filesystems don't represent them). Git checkout with core.fileMode=false (Windows default) preserves this.
  3. In _render_file, src_mode = src_abspath.stat().st_mode — on Windows this returns a mode without S_IXUSR/GRP/OTH, because Python's os.stat doesn't derive exec bits from filesystem state for regular files.
  4. So src_mode & 0o111 == 0, and copier thinks the template file is non-executable. The exec bit is lost end-to-end, regardless of core.fileMode on the destination.

Fix: new Template.git_index_modes cached property reads the template's intended file modes directly from the template clone's git index via a single git ls-files --stage call. _render_file now consults this for exec-bit determination, merging the git-tracked exec bits with the filesystem's non-exec flags, and falls back to pure stat() for non-git templates and untracked files.

This means copier now correctly propagates template exec bits across all platform combinations: Linux↔Linux, Linux→Windows, macOS↔macOS, etc.

4. Windows-only regression test (new)

test_update_preserves_exec_bit_authored_on_unix — deliberately sets up a template using only git update-index --chmod (no Path.chmod, which would be a no-op on Windows anyway) and verifies the exec bit survives end-to-end into the destination's git index on core.fileMode=false. This test is Windows-only because on Linux/macOS git checkout restores the exec bit on disk during the template clone, so stat().st_mode independently arrives at the right answer — the test would pass on Linux even if the git_index_modes code path were broken, giving a false positive for the Windows scenario it's meant to cover. Keeping it Windows-only keeps the intent clear.

5. Merge conflict regression test (new)

test_update_with_exec_bit_change_and_merge_conflict — parametrized over core.fileMode=[True, False]. Deliberately creates a merge conflict on a file that also gains +x between template versions, then asserts the conflict is properly registered (UU in porcelain, conflict markers in the working tree). This locks in the ordering guarantee discussed above, and specifically catches the failure mode that the --chmod--cacheinfo fix addresses.

Verification

  • macOS: uv run poe test → 1063 passed, 5 skipped, 8 xfailed, 1 xpassed. Zero failures. All new tests green. Rebased onto latest master and re-ran: still green.
  • Windows: all new exec-bit tests green, including test_update_preserves_exec_bit_authored_on_unix on a real Windows machine for the first time (previously only reasoned about). The full suite on this Windows machine also reported 5 pre-existing symlink-related failures in tests/test_symlinks.py and tests/test_dirty_local.py. These look like local-environment issues (symlink emulation / developer mode / autocrlf) and they reproduce identically on master on the same machine, so they're unrelated to this PR.

Summary of commit

Single amended commit with:

  • copier/_main.py:
    • _render_allowed gains an expected_mode parameter so mode-only changes are no longer classified as "identical"
    • _render_file consults self.template.git_index_modes for exec-bit determination
    • New _sync_git_index_executable_bit helper, only active on core.fileMode=false, uses --cacheinfo
  • copier/_template.py:
    • New Template.git_index_modes cached property
  • tests/test_updatediff.py:
    • 4 parametrized exec-bit tests (addition/removal × core.fileMode=true/false), with the true case asserting the change is unstaged after update as an auto-staging regression guard
    • Windows-only end-to-end test
    • Merge-conflict regression test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

copier update does not propagate executable-bit changes from template to destination

2 participants