fix(update): detect EOL-only churn via numstat, not name-only

_normalize_managed_eol isolated line-ending churn from real edits by
diffing twice: all dirty files minus files still dirty under
--ignore-cr-at-eol. But 'git diff --name-only --ignore-cr-at-eol'
computes its file list from blob/stat differences BEFORE the CR filter
is applied, so it still lists CR-only files. On git 2.48.1 the two
name-only sets are therefore identical, _eol_only() is always empty, and
a managed Windows checkout gets pinned to core.autocrlf=false with the
whole CRLF tree left dirty — breaking the next 'git checkout' on update
(the exact failure this function exists to prevent).

Compute the real-edit set with 'git diff --numstat --ignore-cr-at-eol'
instead: numstat honors the CR filter (a CR-only file produces no
record), so eol-only files are correctly identified and cleared while
genuine edits are preserved. Pin core.quotepath=false so non-ASCII paths
parse. Verified at 1200 files: 1199 eol-only normalized, one real edit
preserved, autocrlf pinned only after the tree reads clean.

This was a pre-existing failure on main (test_update_eol_churn's
test_churn_across_more_files_than_fit_in_one_argv failed deterministically
on git 2.48.1), surfaced while landing unrelated file-tools PRs.
This commit is contained in:
Teknium
2026-08-01 14:35:35 -07:00
parent 9772e3b189
commit ff6ed7c491

View File

@@ -3474,8 +3474,36 @@ def _normalize_managed_eol(git_cmd, repo_root):
return None
return {p for p in out.stdout.split("\0") if p}
def _real_dirty():
# Files with a *content* change once CRLF differences are ignored.
# NOTE: ``diff --name-only --ignore-cr-at-eol`` still LISTS CR-only
# files (the name list is computed from blob/stat differences before
# the CR filter is applied), so it cannot be used to isolate real
# edits. ``--numstat`` does honor the filter: a CR-only file produces
# no numstat record, while a genuinely-edited file does. Parse the
# paths out of numstat instead.
out = subprocess.run(
probe + ["-c", "core.quotepath=false",
"diff", "--numstat", "--ignore-cr-at-eol"],
cwd=repo_root,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if out.returncode != 0:
return None
paths = set()
for line in out.stdout.splitlines():
if not line.strip():
continue
# Format: "<added>\t<deleted>\t<path>". Rename detection is off in
# plain diff, so there is exactly one path field per record.
parts = line.split("\t", 2)
if len(parts) == 3 and parts[2]:
paths.add(parts[2])
return paths
def _eol_only():
all_dirty, real_dirty = _dirty(), _dirty("--ignore-cr-at-eol")
all_dirty, real_dirty = _dirty(), _real_dirty()
if all_dirty is None or real_dirty is None:
return None
return all_dirty - real_dirty