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..1f143b67e2 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_bytes(b"a\nb\n") + + 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_bytes(b"a\nb\nc\n") + _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"