From 2db4ed2d11d41755265970293bcaf9b31883e72a Mon Sep 17 00:00:00 2001 From: abhishekacharya200 Date: Wed, 28 Oct 2026 18:00:00 +0545 Subject: [PATCH 1/2] fix(git): pin autocrlf=false on the uncached fallback clone and checkout The fallback working-tree clone freezes the host's effective git config into GIT_CONFIG_KEY_n, so a host with core.autocrlf=true wrote CRLF bytes while the follow-up `git checkout ` ran without that snapshot and saw every text file as modified, failing the install after the producer gained a commit. Pin core.autocrlf=false on both commands, as the GitCache and bare-cache paths already do (#2982), so the materialized bytes are the committed bytes and the two commands agree. Closes #3368 --- CHANGELOG.md | 4 ++ src/apm_cli/utils/git_env.py | 17 +++++ tests/unit/cache/test_git_env.py | 112 +++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82960dc779..026f14f600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The uncached Git fallback pins `core.autocrlf=false` on both its working-tree clone and the checkout that follows, so a host whose gitconfig sets `core.autocrlf=true` no longer materializes CRLF bytes and then fails `apm install` with "Your local changes would be overwritten by checkout" when the locked commit differs from the branch head. (#3368) + ## [0.35.0] - 2026-10-22 ### Added diff --git a/src/apm_cli/utils/git_env.py b/src/apm_cli/utils/git_env.py index 1523d791bd..44b5d98a32 100644 --- a/src/apm_cli/utils/git_env.py +++ b/src/apm_cli/utils/git_env.py @@ -490,6 +490,21 @@ def git_no_templates_args() -> tuple[str]: return ("--template=",) +def git_autocrlf_pin_args() -> tuple[str, str]: + """Return the canonical per-command pin against host CRLF conversion. + + ``core.autocrlf`` snapshots frozen into ``GIT_CONFIG_KEY_n`` by + :func:`git_clone_env` (apm#2971) outrank repository-local config, so on a + host whose system gitconfig sets ``core.autocrlf=true`` a working-tree + clone rewrites LF-committed blobs as CRLF. A checkout that runs without + the snapshot then compares that CRLF worktree against the LF index and + refuses the switch with "local changes would be overwritten" (apm#3368). + ``-c`` outranks the snapshots, so clone and checkout agree and the + materialized bytes are the committed bytes. + """ + return "-c", "core.autocrlf=false" + + class _GitProgress(Protocol): """Git progress callback consumed by the clone subprocess adapter.""" @@ -1395,6 +1410,7 @@ def clone_git_worktree( args = [ get_git_executable(), *git_no_hooks_args(), + *git_autocrlf_pin_args(), "clone", *git_no_templates_args(), ] @@ -1473,6 +1489,7 @@ def checkout_git_worktree( [ get_git_executable(), *git_no_hooks_args(), + *git_autocrlf_pin_args(), "-C", str(worktree), "checkout", diff --git a/tests/unit/cache/test_git_env.py b/tests/unit/cache/test_git_env.py index 3e3e6b54db..162c5082a5 100644 --- a/tests/unit/cache/test_git_env.py +++ b/tests/unit/cache/test_git_env.py @@ -19,9 +19,11 @@ GitUrlRewriteProbeError, _GitConfigSnapshot, _resolve_trusted_executable, + checkout_git_worktree, clone_git_worktree, get_gh_executable, get_git_executable, + git_autocrlf_pin_args, git_network_env, git_remote_refs, git_subprocess_env, @@ -1606,3 +1608,113 @@ def test_clone_timeout_does_not_expose_authenticated_url(self, tmp_path) -> None ) assert token not in str(raised.value) + + +class TestUncachedFallbackLineEndings: + """The uncached fallback clone and its checkout must agree on line endings. + + A host ``core.autocrlf=true`` is frozen into the clone's environment + (apm#2971), which rewrote LF-committed blobs as CRLF; the follow-up + ``git checkout `` then saw every file as modified and refused + (apm#3368). + """ + + def test_clone_and_checkout_pin_autocrlf_false(self, tmp_path) -> None: + assert git_autocrlf_pin_args() == ("-c", "core.autocrlf=false") + + with patch( + "apm_cli.utils.git_env.subprocess.run", + side_effect=_run_real_git_config_and_fake_clone, + ) as run: + clone_git_worktree( + "https://git.example.com/acme/repo", + tmp_path / "clone", + env={"PATH": os.environ.get("PATH", "")}, + ) + + clone_argv = run.call_args_list[-1].args[0] + clone_pin = clone_argv.index("core.autocrlf=false") + assert clone_argv[clone_pin - 1] == "-c" + assert clone_pin < clone_argv.index("clone") + + with patch("apm_cli.utils.git_env.subprocess.run") as checkout_run: + checkout_git_worktree( + tmp_path / "clone", + "0123456789abcdef0123456789abcdef01234567", + env={"PATH": os.environ.get("PATH", "")}, + ) + + checkout_argv = checkout_run.call_args.args[0] + assert "-c" in checkout_argv + assert "core.autocrlf=false" in checkout_argv + assert checkout_argv.index("core.autocrlf=false") < checkout_argv.index("checkout") + + def test_locked_sha_checks_out_from_a_crlf_host(self, tmp_path, monkeypatch) -> None: + """End-to-end reproduction: clone under a CRLF host, then check out an older SHA.""" + git = get_git_executable() + host_config = tmp_path / "host.gitconfig" + host_config.write_text("[core]\n\tautocrlf = true\n", encoding="utf-8") + empty_config = tmp_path / "empty.gitconfig" + empty_config.write_text("", encoding="utf-8") + + src = tmp_path / "src" + src.mkdir() + subprocess.run([git, "init", "-q", "-b", "main", str(src)], check=True) + tracked = src / "f.txt" + tracked.write_text("a\nb\n", encoding="utf-8") + + def _commit(message: str) -> None: + subprocess.run( + [git, "-C", str(src), "-c", "user.name=t", "-c", "user.email=t@t", "add", "f.txt"], + check=True, + ) + subprocess.run( + [ + git, + "-C", + str(src), + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-qm", + message, + ], + check=True, + ) + + _commit("one") + old_sha = subprocess.run( + [git, "-C", str(src), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + tracked.write_text("a\nb\nc\n", encoding="utf-8") + _commit("two") + + # Host-level config that the clone snapshot freezes into the child env. + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(host_config)) + monkeypatch.delenv("GIT_CONFIG_NOSYSTEM", raising=False) + reset_git_cache() + isolated = {"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(empty_config)} + + target = tmp_path / "wt" + clone_git_worktree(f"file://{src.resolve()}", target, env=isolated) + + assert tracked.read_bytes().replace(b"\r\n", b"\n") == b"a\nb\nc\n" + assert (target / "f.txt").read_bytes() == b"a\nb\nc\n", ( + "clone must write the committed LF bytes" + ) + + checkout_git_worktree(target, old_sha, env=isolated) + + head = subprocess.run( + [git, "-C", str(target), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert head == old_sha + assert (target / "f.txt").read_bytes() == b"a\nb\n" From 8c332e5e76c60c0c7f57b77319fbc81f08ac0fee Mon Sep 17 00:00:00 2001 From: abhishekacharya200 Date: Sat, 31 Oct 2026 17:00:13 +1100 Subject: [PATCH 2/2] test(git): write fixture files as LF bytes write_text produces CRLF on Windows, and with core.autocrlf off those bytes would reach the fixture commits and fail the LF assertions. --- tests/unit/cache/test_git_env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/cache/test_git_env.py b/tests/unit/cache/test_git_env.py index 162c5082a5..1f143b67e2 100644 --- a/tests/unit/cache/test_git_env.py +++ b/tests/unit/cache/test_git_env.py @@ -1661,7 +1661,7 @@ def test_locked_sha_checks_out_from_a_crlf_host(self, tmp_path, monkeypatch) -> src.mkdir() subprocess.run([git, "init", "-q", "-b", "main", str(src)], check=True) tracked = src / "f.txt" - tracked.write_text("a\nb\n", encoding="utf-8") + tracked.write_bytes(b"a\nb\n") def _commit(message: str) -> None: subprocess.run( @@ -1691,7 +1691,7 @@ def _commit(message: str) -> None: text=True, check=True, ).stdout.strip() - tracked.write_text("a\nb\nc\n", encoding="utf-8") + tracked.write_bytes(b"a\nb\nc\n") _commit("two") # Host-level config that the clone snapshot freezes into the child env.