fix(cache): pin core.autocrlf=false on GitCache checkouts - #2982
Daniel Meppiel (danielmeppiel) merged 4 commits into
Conversation
Git-subpath materialization went through GitCache without the CRLF pin that bare_cache already set, so Windows hosts with system core.autocrlf=true recorded non-portable content_hash values. Add a -c pin that outranks env-frozen host config, persist it on the checkout, and rematerialize unpinned shards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved cache-healing concurrency and validation issues, along with documentation and scope corrections, block approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR makes GitCache checkout content more consistent across hosts by pinning core.autocrlf=false, healing legacy shards, and updating tests and documentation.
Changes:
- Pins Git checkout configuration and rematerializes unpinned cache shards.
- Adds real-Git regression coverage.
- Updates lockfile guidance, troubleshooting documentation, and the changelog.
File summaries
| File | Summary |
|---|---|
tests/unit/cache/test_git_cache.py |
Updates cache-hit fixtures. |
tests/unit/cache/test_git_cache_recency.py |
Updates recency fixtures. |
tests/unit/cache/test_git_cache_hardening.py |
Updates hardening fixtures. |
tests/unit/cache/test_git_cache_autocrlf.py |
Adds cross-platform autocrlf regression tests. |
tests/integration/test_git_cache_hermetic.py |
Updates checkout command expectations. |
src/apm_cli/cache/git_cache.py |
Adds autocrlf pinning and legacy-shard healing. |
docs/src/content/docs/troubleshooting/common-errors.md |
Updates hash-mismatch guidance. |
docs/src/content/docs/reference/lockfile-spec.md |
Documents Git checkout hash behavior. |
CHANGELOG.md |
Adds the unreleased fix entry. |
Review details
Suppressed comments (8)
docs/src/content/docs/reference/lockfile-spec.md:220
- This says
Git checkoutsgenerally, but the changed pin is inGitCacheand the approved scope explicitly excludes whole-repo materialization. Also,core.autocrlf=falsedoes not override repository.gitattributes/core.eolchoices, which this PR leaves as a follow-up. Narrow the contract to the GitCache-backed git-subpath path and state that limitation, otherwise the lockfile spec promises cross-OS hashes for paths this PR does not fix.
| `content_hash` | string | no | SHA-256 of the materialized package tree, computed from sorted relative paths and raw file bytes. Git checkouts pin `core.autocrlf=false` so LF-committed content hashes the same on every OS; bytes that are committed as CRLF stay CRLF. For remote dependencies it verifies that downloaded or cached content still matches the lock; for local path dependencies it detects source-tree changes. |
src/apm_cli/cache/git_cache.py:670
- If eviction fails,
robust_rmtreeis called withignore_errors=True, so this path still proceeds with the oldfinal_dir.atomic_landcan then report a loser and the existing fallback only verifies the SHA, allowing the unpinned checkout to be returned without healing it. Fail closed whenfinal_dirremains after eviction, or make the fallback require the autocrlf pin as well.
self._evict_checkout(final_dir)
src/apm_cli/cache/git_cache.py:106
- This substring search does not establish that the
core.autocrlfkey is set to false: a remote URL or comment containingautocrlf=falsewould make an old CRLF shard look healed and skip rematerialization. Parse the local[core]key (or querygit config --local --get --type=bool) and compare its normalized value.
return "autocrlf = false" in text or "autocrlf=false" in text
src/apm_cli/cache/git_cache.py:89
- This helper is used for every
GitCachecheckout, and_create_checkoutpersists and recognizes the pin regardless ofsparse_paths; whole-repository downloads call this cache with no sparse paths. That changes and migrates whole-repo cache shards even though the PR description and the approved issue scope list whole-repo behavior as out of scope. Please either constrain the migration/pin to subpath variants or update the approved scope and coverage to include this behavior.
"-c",
"submodule.recurse=false",
"-c",
"core.autocrlf=false",
]
src/apm_cli/cache/git_cache.py:105
- A corrupted or non-UTF-8
.git/configmakesread_textraiseUnicodeDecodeError, which is not anOSError; the cache-hit path then aborts instead of treating the pin as missing and rematerializing, contrary to the defensive false result intended here. CatchUnicodeErroras well.
try:
text = config.read_text(encoding="utf-8")
except OSError:
return False
tests/unit/cache/test_git_cache_autocrlf.py:26
- This real-Git fixture bypasses the repository's trusted executable resolver and launches a literal
gitcommand. Because these tests run in the Windows compatibility gate, useget_git_executable()here so the fixture exercises the same PATH-independent binary selection as GitCache.
["git", *args],
tests/unit/cache/test_git_cache_autocrlf.py:17
- This new module runs real Git subprocesses and writes checkout trees, so it belongs to the repository's
componentbehavioral boundary, but it declares only the schedulingwindows_compatmarkers. Add a module-level component marker so the taxonomy inventory classifies every collected node consistently.
import pytest
from apm_cli.cache.git_cache import GitCache, _safe_git_args
from apm_cli.utils.content_hash import compute_package_hash
tests/unit/cache/test_git_cache_autocrlf.py:39
_neutral_git_envleaves inheritedGIT_CONFIG_NOSYSTEMandGIT_CONFIG_PARAMETERSin place. If either is set by the runner, the synthetic system config from_host_autocrlf_true_envcan be ignored or overridden, allowing this regression to pass without exercising the host-config failure. Clear both in the neutral environment.
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env.pop("GIT_CONFIG_COUNT", None)
- Files reviewed: 9/9 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Defer unpinned SHA-valid eviction until _create_checkout holds the shard lock, parse the local core.autocrlf key, fail closed on land races, and document legacy lockfile regeneration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 0 | The bounded GitCache fix preserves canonical ownership and safe publication. One fixture-environment concern remains; no reachable production architecture defect found. |
| CLI Logging Expert | 0 | 0 | 1 | Default output stays quiet; URLs and new failure diagnostics use canonical redaction. One duplicate-log nit. |
| DevX UX Expert | 0 | 1 | 0 | The bounded checkout fix preserves raw-hash semantics. Legacy-lock recovery guidance still needs an explicit update path and fresh-materialization prerequisite. |
| Supply Chain Security | 0 | 1 | 0 | Cache integrity remains fail-closed; one inherited-environment regression-fixture gap remains. |
| OSS Growth Hacker | 0 | 0 | 1 | Legacy-lock recovery is actionable; one release-note claim could match the documented portability boundary more precisely. |
| Auth Expert | 0 | 1 | 0 | No auth transport or credential-handling regression found. The real-Git fixture still incompletely clears inherited configuration. |
| Doc Writer | 0 | 2 | 1 | Current docs resolve the earlier scope overclaim; correct the bare apm lock recovery command and refresh the PR body's promises and evidence. |
| Test Coverage | 0 | 3 | 0 | 114 focused tests pass. The hostile-config fixture still leaks inherited overrides; frozen host-switch replay and failed-healing regression traps remain worth adding. |
| Performance Expert | 0 | 1 | 0 | Warm-hit complexity remains bounded. One optional cold-path improvement removes a measured Git spawn per new or rematerialized checkout. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 3 follow-ups
- [Test Coverage] Fix fixture isolation at tests/unit/cache/test_git_cache_autocrlf.py:90 and the other three checkout setups: explicitly remove inherited GIT_CONFIG_NOSYSTEM and GIT_CONFIG_PARAMETERS, then assert effective host autocrlf=true before exercising GitCache. -- Four disposable precondition assertions failed while the shipped checkout tests passed. Keep the correction test-local and retain the existing byte/hash assertions; no production environment-policy change is needed.
- [Test Coverage] Reuse tests/integration/test_git_cache_recency_lifecycle.py for one parameterized pinned-LF git-subpath scenario: generate a lock under each system-autocrlf setting, remove owned consumer/cache materializations, and replay with --frozen under the opposite setting. -- Assert successful replay, unchanged lock bytes, and the locked raw package hash in both directions. Existing lifecycle coverage does not exercise this specific portability criterion. This closes a proof gap without inventing a frozen-install failure or expanding into a broad matrix.
- [Doc Writer] Correct docs/src/content/docs/troubleshooting/common-errors.md:90 and verify the documented recovery sequence: require a fresh consumer workspace or rematerialization of the affected installed package, then explicit apm lock --update or apm install --update; warn that mutable refs can advance and the SHA/hash diff needs review. -- Source analysis, not an executed CLI reproduction, shows that bare apm lock retains the existing hash check. A matching same-SHA CRLF apm_modules copy can also be reused even with --update. Healing GitCache alone does not regenerate a legacy project lock.
Architecture
Class and component diagrams
classDiagram
direction LR
class GitHubPackageDownloader {
+download_subdirectory_package()
+download_package()
-_persistent_cache_checkout()
}
class GitCache {
<<CacheAside>>
+get_checkout() Path
-_ensure_bare_repo()
-_create_checkout() Path
-_finalize_sparse_checkout() Path
}
class CheckoutPolicy {
<<Module>>
_safe_git_args()
_checkout_pins_autocrlf_false()
}
class Integrity {
<<Module>>
verify_checkout_sha()
}
class CacheLocking {
<<Module>>
shard_lock() FileLock
stage_path() Path
atomic_land() bool
}
class FileLock {
<<Mutex>>
}
class GitEnvironment {
<<Module>>
get_git_executable()
git_subprocess_env()
git_network_env()
}
class GitSparse {
<<Module>>
apply_sparse_cone()
repair_dangling_cone_symlinks()
}
GitHubPackageDownloader --> GitCache : persistent checkout
GitCache ..> CheckoutPolicy : invocation and migration pin
GitCache ..> Integrity : HEAD verification
GitCache ..> CacheLocking : stage and publish
CacheLocking ..> FileLock : creates and acquires
GitCache ..> GitEnvironment : execution environment
GitCache ..> GitSparse : configure and repair cone
note for GitCache "Cache-aside: reuse validated shards; rebuild misses and legacy shards"
note for CheckoutPolicy "Functions in cache/git_cache.py; not a new runtime class"
class GitCache:::touched
class CheckoutPolicy:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
CLI["apm install<br/>commands/install.py: install"] --> SUB["deps/github_downloader.py:<br/>download_subdirectory_package"]
CLI --> FULL["deps/github_downloader.py:<br/>download_package"]
SUB --> ENTRY["_persistent_cache_checkout"]
FULL --> ENTRY
ENTRY --> GET["cache/git_cache.py: GitCache.get_checkout<br/>_resolve_sha and _variant_key"]
GET --> HIT{"[I/O] Non-refresh hit:<br/>verify_checkout_sha AND<br/>_checkout_pins_autocrlf_false?"}
HIT -->|yes| REUSE["[LOCK] shard_lock<br/>_finalize_sparse_checkout<br/>[I/O] _record_checkout_access"]
REUSE --> RETURN["Return checkout Path to downloader"]
HIT -->|no| LEGACY["Retain SHA-valid unpinned tree;<br/>[FS] evict SHA-invalid hit"]
LEGACY --> BARE["[LOCK] [NET] [EXEC]<br/>_ensure_bare_repo"]
BARE --> CREATE["[LOCK] _create_checkout:<br/>shard_lock before staging"]
CREATE --> DEDUP{"[I/O] Locked re-probe:<br/>SHA and local pin valid?"}
DEDUP -->|yes| REUSE
DEDUP -->|no| OLD{"SHA-valid unpinned final_dir?"}
OLD -->|yes| EVICT["[FS] _evict_checkout"]
EVICT --> REMAINS{"[I/O] final_dir still exists?"}
REMAINS -->|yes| ERROR["Raise RuntimeError;<br/>do not return old shard"]
REMAINS -->|no| STAGE["[FS] stage_path beside final_dir"]
OLD -->|no| STAGE
STAGE --> CLONE["[EXEC] clone --local --shared --no-checkout<br/>_safe_git_args includes core.autocrlf=false"]
CLONE --> PIN["[EXEC] Persist config core.autocrlf false<br/>before materializing working-tree bytes"]
PIN --> SPARSE{"sparse_paths / promisor consumer?"}
SPARSE -->|yes| CONE["[EXEC] git_network_env<br/>apply_sparse_cone with _safe_git_args"]
SPARSE -->|no| CHECKOUT["[EXEC] checkout SHA<br/>-c core.autocrlf=false outranks frozen config"]
CONE --> CHECKOUT
CHECKOUT --> FINALIZE["[I/O] _finalize_sparse_checkout<br/>[EXEC] repair_dangling_cone_symlinks if needed"]
FINALIZE --> LAND{"[FS] cache/locking.py:<br/>atomic_land succeeded?"}
LAND -->|yes| RETURN
LAND -->|no| WINNER{"[I/O] Winner SHA and pin valid?"}
WINNER -->|yes| RETURN
WINNER -->|no| FAILURE["[FS] _evict_checkout<br/>raise RuntimeError"]
Recommendation
Source-correctness review supports shipping the bounded implementation. Prefer folding the small fixture-isolation and recovery-doc corrections into this PR, and keep the single host-switch frozen replay explicitly unfinished until executable evidence establishes the issue's done criterion. Do not describe the current tests as proving that roundtrip, or inflate their absence into a demonstrated runtime defect. The maintainer and author retain the shipping decision and independent formal review.
Full per-persona findings
Python Architect
-
[recommended] Apply fixture environment deletions, not just the remaining key/value pairs at
tests/unit/cache/test_git_cache_autocrlf.py:90
_neutral_git_env removes GIT_CONFIG_NOSYSTEM and GIT_CONFIG_PARAMETERS from its returned copy, but the four per-test setenv loops do not remove those keys from os.environ. Their subsequent deletion loops only handle COUNT/KEY/VALUE entries. The synthetic system autocrlf=true configuration can therefore remain disabled or overridden. I reproduced the current fixture setup with real Git: inherited NOSYSTEM=1 left autocrlf unset, and inherited PARAMETERS='core.autocrlf=false' selected false; explicitly removing both restored true. tests/conftest.py does not clear these variables. This weakens the regression trap rather than demonstrating a production failure.Design patterns
- Used in this PR: Cache-aside -- GitCache reuses valid shards and rebuilds legacy shards through its existing locked publication path.
- Pragmatic suggestion: none -- retain the bounded production design; consolidate the repeated environment application into one small fixture helper.
Suggested: Explicitly monkeypatch.delenv both variables before invoking GitCache in all four real-checkout cases. Share that setup locally, and assert that the effective pre-checkout host configuration actually reports autocrlf=true.
Proof (manual, manual-only):tests/unit/cache/test_git_cache_autocrlf.py::test_full_checkout_keeps_lf_under_system_autocrlf_true-- proves: The host-autocrlf regression can assert LF bytes without establishing the intended system autocrlf=true precondition. [portability-by-manifest]
assert skill.read_bytes() == _LF_BODY
CLI Logging Expert
- [nit] Emit the rematerialization message once. at
src/apm_cli/cache/git_cache.py:254
An unpinned hit logs here, then again at line 683 under lock. Only verbose/INFO output is affected; default WARNING remains quiet.
Suggested: Keep the locked emission; remove the preliminary one.
DevX UX Expert
- [recommended] Bare apm lock does not reliably regenerate a legacy CRLF hash at
docs/src/content/docs/troubleshooting/common-errors.md:90
The documented recovery must actually escape the mismatch. commands/lock.py defaults update_refs to false and forwards it unchanged. FreshDependencySource.acquire in install/sources.py:814-840 rejects a freshly downloaded LF hash against the existing CRLF hash unless update_refs or a separately authorized hash-change condition applies; lockfile_only does not bypass this check. Consequently, bare apm lock can reproduce the same error. An existing CRLF apm_modules copy can also be reused when its hash matches the old lock, including under --update when the resolved SHA is unchanged (install/phases/integrate.py:123-146). Healing GitCache shards is not equivalent to regenerating the project lock.
Suggested: Document recovery from a fresh consumer workspace, or explicitly require rematerializing the affected installed package, then use apm lock --update or apm install --update. Explain that --update can advance mutable refs and require reviewing the SHA/hash diff before committing. Keep normal and frozen integrity validation unchanged.
Supply Chain Security
- [recommended] Remove inherited config overrides when applying the test environment at
tests/unit/cache/test_git_cache_autocrlf.py:90
_neutral_git_env removes GIT_CONFIG_NOSYSTEM and GIT_CONFIG_PARAMETERS only from its copy. The setup loops merge that copy into os.environ without deleting those inherited keys. An isolated execution of the actual setup followed by real Git config lookup observed autocrlf=true with clean input, no value with NOSYSTEM=1, and false with PARAMETERS overriding autocrlf. The regression can therefore miss its intended hostile-host precondition. This is a validation gap, not a production security bypass.
Suggested: Explicitly monkeypatch.delenv both keys in all four checkout setups, alongside the existing COUNT/KEY/VALUE cleanup; assert the synthetic host config is effective before exercising GitCache.
Proof (manual, manual-only):tests/unit/cache/test_git_cache_autocrlf.py-- proves: The host-autocrlf regression can run without its required core.autocrlf=true precondition. [portability-by-manifest,secure-by-default]
OSS Growth Hacker
- [nit] Describe the autocrlf fix rather than unconditional cross-platform hash equality. at
CHANGELOG.md:29
The lockfile specification correctly preserves .gitattributes/core.eol exceptions. LF-committed content alone does not establish the broader equality promised here.
Suggested: Replace the equality clause with 'prevent host autocrlf settings from rewriting LF-committed package content'. No additional EOL policy is needed.
Auth Expert
- [recommended] Remove inherited NOSYSTEM and PARAMETERS when applying the fixture environment at
tests/unit/cache/test_git_cache_autocrlf.py:114
_neutral_git_env removes both keys from its returned copy, but the tests only set entries from that copy. Existing os.environ values survive; subsequent cleanup removes only COUNT/KEY/VALUE entries. A standalone real-Git probe reproduced effective autocrlf=true with clean setup, unset with inherited GIT_CONFIG_NOSYSTEM=1, and false with inherited GIT_CONFIG_PARAMETERS specifying false. Thus the regression can pass without exercising the intended host autocrlf=true condition. This is a fixture-isolation issue, not an application auth bypass.
Suggested: Explicitly monkeypatch.delenv both variables when applying host_env in all four real-checkout tests. Assert the effective host autocrlf value is true before materialization. Keep this cleanup test-local; production intentionally preserves user configuration.
Proof (manual, manual-only):tests/unit/cache/test_git_cache_autocrlf.py::test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_true-- proves: The sparse-checkout regression's host-autocrlf precondition is not guaranteed under inherited runner configuration. [portability-by-manifest]
Doc Writer
-
[recommended] Bare apm lock does not repair an existing CRLF content_hash at
docs/src/content/docs/troubleshooting/common-errors.md:90
The new recovery instruction offersapm lockalongsideapm install --update, but they are not equivalent. Bare lock defaults update_refs to false and forwards it unchanged into the install pipeline (commands/lock.py:106-110,228-237). The pipeline loads the existing lockfile, and fresh materialization still rejects a different hash when update_refs is false (install/sources.py:814-839). Thus a clean checkout with the legacy CRLF lock can encounter the same mismatch instead of recording the corrected LF hash. This is a documentation command error, not a request to relax integrity verification.
Suggested: Remove bareapm lockfrom the recovery parenthetical and retainapm install --update. If retaining a lock-only alternative for fresh materialization, useapm lock --update; note that update can advance mutable refs. -
[recommended] Align PR-body scope and benefits with the qualified documentation
PR body sections 'TL;DR' and 'Benefits' still describe whole-repo behavior as excluded and promise replay of a lockfile generated on either OS without the current documentation's qualifications. The shared GitCache owner also serves regular whole-repo downloads (github_downloader.py:1895-1910), so those cache variants receive the same pin and rematerialization behavior. Separately, existing CRLF locks require regeneration, and the pin does not override .gitattributes or core.eol. The body should distinguish the bounded subpath objective from this shared-owner effect rather than imply whole-repo behavior is untouched.
Suggested: Add one sentence acknowledging that full GitCache variants inherit the shared pin. Qualify Benefits 1-2 to the targeted autocrlf conversion and newly generated or regenerated locks, referencing the existing EOL limitation. No cache fork or broader configuration framework is needed. -
[nit] Refresh the PR body's test checklist and evidence labels
PR body 'How to test' still says four tests, while the current regression module contains seven. 'Scenario Evidence' labels the real-checkout cases as unit even though they use real Git and filesystem I/O and now carry the component marker. 'Implementation' links the initial 8f1693b revision, and Validation's 85/250 results and mutation claim are earlier author-reported evidence rather than current local results.
Suggested: Change four to seven, label the real-checkout scenarios component/integration-with-fixtures, refresh implementation permalinks, and identify the older validation results by revision instead of presenting them as exact-head evidence.
Test Coverage
-
[recommended] Apply the neutral environment's deletions before certifying the hostile-host regression. at
tests/unit/cache/test_git_cache_autocrlf.py:90
neutral_git_env removes NOSYSTEM/PARAMETERS from its copy, but lines 90-95 only set surviving keys and delete COUNT/KEY/VALUE_. Inherited NOSYSTEM/PARAMETERS remain in os.environ. Four real-Git probes called the existing full/sparse tests: both tests passed with NOSYSTEM=1 while effective autocrlf was unset, and with PARAMETERS='core.autocrlf=false' while it was false. Clean controls observed true and passed. This is a reproducible false-positive regression fixture, not a production checkout defect; the earlier suppressed concern is only partially fixed.
Suggested: Explicitly monkeypatch.delenv both inherited keys when applying host_env in these tests, not merely in the copied dictionary. Assert Git sees the intended hostile autocrlf=true before the cache call; keep the existing byte/hash assertions.
Proof (failed, integration-with-fixtures):.review-pr2982-tests/test_review_probes.py::test_host_fixture_actually_exposes_autocrlf_true(disposable local probe; removed after execution) -- proves: The full/sparse LF checkout regression must actually exercise the hostile host setting before certifying portable package hashes. [portability-by-manifest,devx]
assert observed == [(0, "true", False)] -
[recommended] Defend the scoped frozen lockfile replay promise through a real git-subpath consumer. at
tests/unit/cache/test_git_cache_autocrlf.py:100
The executed full/sparse tests are integration-with-fixtures evidence despite their unit directory, but stop at GitCache and raw hashing: none writes or replays an APM lockfile. Searched tests/**/*.py for autocrlf/GIT_CONFIG_SYSTEM and read the frozen/roundtrip candidates. test_git_cache_recency_lifecycle covers frozen reuse without switching host config; test_virtual_claude_skill_lock_convergence disables GitCache; test_install_content_hash_roundtrip mocks raw download and tests synthetic manifests, not this checkout path. Thus the approved either-platform frozen replay criterion has no matching automated consumer scenario. This is a proof gap, not evidence that frozen installs fail.
Suggested: Reuse the existing frozen-rehydrate lifecycle fixture for one pinned LF git-subpath scenario: generate the lock under each system-autocrlf setting, remove owned materializations/cache, then install --frozen under the opposite setting. Assert success, unchanged lock bytes, and the same raw package hash. No additional unrelated lifecycle matrix is needed.
Proof (missing, e2e):tests/integration/test_git_cache_recency_lifecycle.py::test_frozen_git_subpath_replays_across_host_autocrlf-- proves: apm install --frozen can replay a git-subpath lockfile generated under either host autocrlf setting without changing its hash. [portability-by-manifest,devx]
assert replay.returncode == 0 assert lock_after == lock_before assert compute_package_hash(materialized_package) == locked_hash -
[recommended] Retain a failure-path trap for a SHA-valid unpinned checkout that cannot be removed. at
src/apm_cli/cache/git_cache.py:690
Current code correctly fails closed, and a disposable real-checkout fault-injection probe passed. However, tests-tree searches for rematerialization/eviction/atomic-land and reads of the matching tests found only successful healing, eviction-helper error swallowing, and loser tests with either a pinned winner or an invalid SHA. No committed test exercises an unremovable SHA-valid unpinned shard. That leaves the recently repaired cache-healing failure behavior without a durable regression trap; this is not a remaining production bug.
Suggested: Extend the existing real-Git healing fixture with a no-op deletion fault and assert get_checkout raises rather than returns the poisoned path. The existing atomic-land test can additionally cover a valid SHA with a missing pin, without introducing real concurrency machinery.
Proof (missing, integration-with-fixtures):tests/unit/cache/test_git_cache_autocrlf.py::test_unremovable_unpinned_checkout_is_not_returned-- proves: An older poisoned cache shard is rebuilt or rejected, never silently returned after healing fails. [portability-by-manifest,devx]
with pytest.raises(RuntimeError, match="Failed to rematerialize unpinned git checkout"): cache.get_checkout(url, sha, locked_sha=sha)
Performance Expert
- [recommended] Persist the pin through the existing clone command instead of spawning Git again at
src/apm_cli/cache/git_cache.py:745
The new setter adds one process for every newly constructed checkout variant: N additional processes across N cold or rematerialized variants. The adjacent clone already persists promisor configuration using clone-level -c options. On this macOS host, 20 isolated config writes had a 22.875 ms median, with substantial variance; this is process-cost evidence, not an install-wall-time estimate. Removing this call is a bounded improvement, not a correctness requirement.
Suggested: Add clone-level '-c core.autocrlf=false' alongside the existing clone options and remove the separate setter. Retain invocation-level _safe_git_args() protection against frozen config. Preserve the real-Git regressions and assert construction subprocess counts at N and 10*N checkouts rather than adding brittle timing thresholds.
Proof (manual, manual-only): -- proves: The persisted checkout marker and invocation-level conversion protection can coexist without a separate configuration-writing process.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Drop inherited GIT_CONFIG_NOSYSTEM/PARAMETERS before the hostile-host regression, assert system autocrlf=true, document lock --update recovery, and reject unremovable unpinned shards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Folded in 78f42b3:
Left unfinished, as recommended: host-switch frozen-replay e2e until that scenario has executable evidence. Also deferred the extra clone-process reduction. |
fix(cache): pin core.autocrlf=false on GitCache checkouts
TL;DR
GitCache._create_checkoutis the default path for git-subpath dependencies (owner/repo/<subdir>#ref). It cloned--no-checkoutand checked out the SHA without pinningcore.autocrlf=false, so a Windows host with the Git for Windows default recorded CRLF packagecontent_hashvalues that Linuxapm install --frozenrejected.This PR adds
-c core.autocrlf=falseto the cache-layer git argv (outranks env-frozen host config), persists the same pin on the checkout, and rematerializes unpinned shards.content_hash.pyis unchanged.Note
Scope approval and review contact: danielmeppiel on #2971. Out of scope: hash-algorithm changes, normalizing intentionally committed CRLF, whole-repo (non-subpath) behavior.
Problem (WHY)
core.autocrlf=true,apm lock/apm installmaterializes git-subpath trees with CRLF.compute_package_hash()hashes raw bytes, so the lockfile is not portable.bare_cache.materialize_from_barealready pinnedcore.autocrlf=false;GitCache(the pathapm installuses by default since persistent cache) did not.git_network_envfreezes host config intoGIT_CONFIG_KEY_n. Env config outranks.git/config; only-cwins.Why these matter:
content_hash.pyalready states that "git-materialized content already does (identical bytes at a pinned commit on every OS)". Host lock-in of lockfile hashes also violates P5 -- Portability over vendor lock-in ("Lock-in of any flavor -- vendor, runtime, host -- is a regression.").Approach (WHAT)
-c core.autocrlf=falseto_safe_git_args()so every GitCache git subprocess outranks host and env-frozen config.core.autocrlf=falsein the consumer.git/configafter--no-checkoutclone so shards are recognizable.apm cache clean.content_hashcontract and the content-hash-mismatch troubleshooting note.Implementation (HOW)
src/apm_cli/cache/git_cache.py-- Extends the existing_safe_git_argsowner (L62-L88). Architecture: ordinary-fix. Did not changecontent_hash.py,git_env.pysnapshot keys, orcore.eol.tests/unit/cache/test_git_cache_autocrlf.py-- Real-git trap:GIT_CONFIG_SYSTEMwithautocrlf=true; full and sparse checkouts; unpinned-shard rematerialize.git configcall.docs/src/content/docs/reference/lockfile-spec.md,docs/src/content/docs/troubleshooting/common-errors.md,CHANGELOG.md-- Contract and Unreleased note.Diagrams
Legend: host
core.autocrlf=truestill exists, but GitCache checkout now pins-cplus local config before writing working-tree bytes.flowchart LR subgraph host [Host] A["core.autocrlf=true"] end subgraph cache [GitCache._create_checkout] B["clone --no-checkout"] C["git config core.autocrlf false"] D["git -c core.autocrlf=false checkout SHA"] end subgraph hash [compute_package_hash] E["raw bytes SHA-256"] end A --> B B --> C C --> D D --> E classDef new stroke-dasharray: 5 5 class C,D newTrade-offs
content_hash. Rejected CRLF-to-LF hashing because the approval record forbids hash-algorithm changes and because committed CRLF must stay visible.-cplus persisted config. Persist alone loses toGIT_CONFIG_KEY_non the sparse path;-calone cannot mark pre-fix shards. Both are required.core.eol=lf. A repo with* text=autocan still check out native EOL underautocrlf=false. That is a separate host knob; left as a follow-up._materialize_git_config_snapshot. Widergit_env.pychange; out of this issue's scope.Benefits
content_hashunder systemcore.autocrlf=trueand on Linux/macOS.apm install --frozencan replay a lockfile generated on either OS.apm cache clean.Validation
Lint (CI-mirror): ruff check, ruff format --check, pylint R0801,
scripts/lint-auth-signals.shall silent / exit 0.Mutation-break: removing
-c core.autocrlf=falsefrom_safe_git_argsmadetest_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_truefail (\rat index 3). Guard restored.pytest (git cache + new trap)
Scenario Evidence
core.autocrlf=true; the packagecontent_hashmatches the LF tree.tests/unit/cache/test_git_cache_autocrlf.py::test_full_checkout_keeps_lf_under_system_autocrlf_trueGIT_CONFIG_KEY_n.tests/unit/cache/test_git_cache_autocrlf.py::test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_truetests/unit/cache/test_git_cache_autocrlf.py::test_cache_hit_rematerializes_unpinned_crlf_shardHow to test
uv run --extra dev pytest -xvs tests/unit/cache/test_git_cache_autocrlf.py— all four tests pass.GIT_CONFIG_SYSTEMat a file withcore.autocrlf=true, lock a git-subpath dep, and confirmSKILL.mdbytes are LF (\nonly).content_hash.pyis untouched and a file committed with CRLF still hashes as CRLF.Closes #2971
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com