chore: enforce LF line endings for all source files via .gitattributes (#85866)

Windows contributors' tools default to CRLF; without repo-level
normalization an edit becomes a whole-file phantom diff (583-line
'change' observed today from one 2-line edit), string-match patch
tooling breaks on invisible \r, and review is polluted. Extend the
existing LF rules (shell/Docker) to every source/text extension:
normalize at check-in AND check out as LF so working trees match the
index on all platforms. *.ps1 stays CRLF (PowerShell 5.1 tooling).

git add --renormalize: exactly one tracked file had mixed endings
(tests/tools/test_windows_agent_loop_papercuts.py) — normalized here,
so no phantom diffs land on anyone's next commit.
This commit is contained in:
Teknium
2026-08-13 23:36:25 -07:00
committed by GitHub
parent efad7c9161
commit 2a6eef77b7
2 changed files with 200 additions and 176 deletions
+24
View File
@@ -8,3 +8,27 @@ web/package-lock.json linguist-generated=true
Dockerfile text eol=lf Dockerfile text eol=lf
*.dockerfile text eol=lf *.dockerfile text eol=lf
docker/entrypoint.sh text eol=lf docker/entrypoint.sh text eol=lf
# Enforce LF for all source/text files. Windows editors and tools default to
# CRLF; without normalization a Windows contributor's edit turns into a
# whole-file phantom diff (every line "changed" by its ending), breaks
# string-match patch tooling, and pollutes review. `text` normalizes to LF
# at check-in; `eol=lf` also checks out as LF so the working tree matches
# the index on every platform. PowerShell files are the deliberate
# exception (PS 5.1 tooling expects CRLF).
*.py text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.css text eol=lf
*.html text eol=lf
*.svg text eol=lf
*.ps1 text eol=crlf
+176 -176
View File
@@ -1,179 +1,179 @@
"""Windows agent-loop correctness regressions. """Windows agent-loop correctness regressions.
Covers the papercut class swept in #84364/#84378's follow-up: silent path Covers the papercut class swept in #84364/#84378's follow-up: silent path
mangling, crashes, and divergent hashing that made day-to-day agent use on mangling, crashes, and divergent hashing that made day-to-day agent use on
Windows unpleasant. Each test names the issue it pins. Windows unpleasant. Each test names the issue it pins.
""" """
import os import os
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from hermes_cli._subprocess_compat import split_command_line from hermes_cli._subprocess_compat import split_command_line
class TestSplitCommandLine: class TestSplitCommandLine:
"""#83934 / #78293 — backslashes in Windows paths must survive splitting.""" """#83934 / #78293 — backslashes in Windows paths must survive splitting."""
@pytest.mark.windows_only @pytest.mark.windows_only
def test_windows_path_backslashes_preserved(self): def test_windows_path_backslashes_preserved(self):
argv = split_command_line(r"sessions export C:\Users\me\Desktop\out.jsonl") argv = split_command_line(r"sessions export C:\Users\me\Desktop\out.jsonl")
assert argv == ["sessions", "export", r"C:\Users\me\Desktop\out.jsonl"] assert argv == ["sessions", "export", r"C:\Users\me\Desktop\out.jsonl"]
@pytest.mark.windows_only @pytest.mark.windows_only
def test_quoted_path_with_spaces(self): def test_quoted_path_with_spaces(self):
argv = split_command_line(r'run "C:\Program Files\App\tool.exe" --flag') argv = split_command_line(r'run "C:\Program Files\App\tool.exe" --flag')
assert argv == ["run", r"C:\Program Files\App\tool.exe", "--flag"] assert argv == ["run", r"C:\Program Files\App\tool.exe", "--flag"]
@pytest.mark.windows_only @pytest.mark.windows_only
def test_bare_hook_command_path(self): def test_bare_hook_command_path(self):
argv = split_command_line(r"C:\Users\u\.local\bin\dcg.exe --hook pre") argv = split_command_line(r"C:\Users\u\.local\bin\dcg.exe --hook pre")
assert argv[0] == r"C:\Users\u\.local\bin\dcg.exe" assert argv[0] == r"C:\Users\u\.local\bin\dcg.exe"
@pytest.mark.linux_only @pytest.mark.linux_only
def test_posix_behavior_unchanged(self): def test_posix_behavior_unchanged(self):
assert split_command_line("echo 'a b' c") == ["echo", "a b", "c"] assert split_command_line("echo 'a b' c") == ["echo", "a b", "c"]
def test_unbalanced_quote_raises(self): def test_unbalanced_quote_raises(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
split_command_line('run "unterminated') split_command_line('run "unterminated')
class TestShellHooksWindowsPaths: class TestShellHooksWindowsPaths:
"""#78293 — hook script paths with backslashes resolve correctly.""" """#78293 — hook script paths with backslashes resolve correctly."""
@pytest.mark.windows_only @pytest.mark.windows_only
def test_command_script_path_keeps_backslashes(self): def test_command_script_path_keeps_backslashes(self):
from agent.shell_hooks import _command_script_path from agent.shell_hooks import _command_script_path
path = _command_script_path(r"C:\hooks\guard.py --strict") path = _command_script_path(r"C:\hooks\guard.py --strict")
assert path == r"C:\hooks\guard.py" assert path == r"C:\hooks\guard.py"
@pytest.mark.windows_only @pytest.mark.windows_only
def test_script_is_executable_finds_real_file(self, tmp_path): def test_script_is_executable_finds_real_file(self, tmp_path):
from agent.shell_hooks import script_is_executable from agent.shell_hooks import script_is_executable
script = tmp_path / "hook.py" script = tmp_path / "hook.py"
script.write_text("print('ok')\n", encoding="utf-8") script.write_text("print('ok')\n", encoding="utf-8")
# #
assert script_is_executable(f'python "{script}"') or script_is_executable( assert script_is_executable(f'python "{script}"') or script_is_executable(
f"python {script}" f"python {script}"
) )
class TestWindowsMarketingVersion: class TestWindowsMarketingVersion:
"""#51755 — Windows 11 must not be reported as Windows 10.""" """#51755 — Windows 11 must not be reported as Windows 10."""
@pytest.mark.windows_only @pytest.mark.windows_only
def test_matches_build_number(self): def test_matches_build_number(self):
from agent.prompt_builder import _windows_marketing_version from agent.prompt_builder import _windows_marketing_version
build = sys.getwindowsversion().build build = sys.getwindowsversion().build
expected = "11" if build >= 22000 else "10" expected = "11" if build >= 22000 else "10"
assert _windows_marketing_version() == expected assert _windows_marketing_version() == expected
def test_fallback_on_lookup_failure(self, monkeypatch): def test_fallback_on_lookup_failure(self, monkeypatch):
import agent.prompt_builder as pb import agent.prompt_builder as pb
if sys.platform == "win32": if sys.platform == "win32":
monkeypatch.delattr(sys, "getwindowsversion") monkeypatch.delattr(sys, "getwindowsversion")
assert isinstance(pb._windows_marketing_version(), str) assert isinstance(pb._windows_marketing_version(), str)
class TestAutocompleteDevicePaths: class TestAutocompleteDevicePaths:
"""#42016 — relpath ValueError on device paths must not escape.""" """#42016 — relpath ValueError on device paths must not escape."""
def test_relpath_valueerror_pattern(self): def test_relpath_valueerror_pattern(self):
# The guarded pattern in _get_project_files: a ValueError from # The guarded pattern in _get_project_files: a ValueError from
# os.path.relpath (different mount) is skipped, not raised. # os.path.relpath (different mount) is skipped, not raised.
bad = "\\\\.\\nul" if sys.platform == "win32" else "/dev/null" bad = "\\\\.\\nul" if sys.platform == "win32" else "/dev/null"
cwd = os.getcwd() cwd = os.getcwd()
files = [] files = []
for p in [bad, os.path.join(cwd, "real.txt")]: for p in [bad, os.path.join(cwd, "real.txt")]:
try: try:
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
except ValueError: except ValueError:
continue continue
files.append(rel) files.append(rel)
assert "real.txt" in files assert "real.txt" in files
class TestBrowserScreenshotPathRegex: class TestBrowserScreenshotPathRegex:
"""#83884 — Windows drive-letter screenshot paths must be detected.""" """#83884 — Windows drive-letter screenshot paths must be detected."""
def _re(self): def _re(self):
from tools.browser_use_cli import _IMAGE_PATH_RE from tools.browser_use_cli import _IMAGE_PATH_RE
return _IMAGE_PATH_RE return _IMAGE_PATH_RE
def test_windows_backslash_path(self): def test_windows_backslash_path(self):
m = self._re().findall(r"Saved screenshot to C:\Users\u\shots\page.png done") m = self._re().findall(r"Saved screenshot to C:\Users\u\shots\page.png done")
assert m == [r"C:\Users\u\shots\page.png"] assert m == [r"C:\Users\u\shots\page.png"]
def test_windows_forward_slash_path(self): def test_windows_forward_slash_path(self):
m = self._re().findall("shot: C:/Users/u/shots/page.jpeg") m = self._re().findall("shot: C:/Users/u/shots/page.jpeg")
assert m == ["C:/Users/u/shots/page.jpeg"] assert m == ["C:/Users/u/shots/page.jpeg"]
def test_posix_path_still_matches(self): def test_posix_path_still_matches(self):
m = self._re().findall("wrote /tmp/bu-task/shot.webp") m = self._re().findall("wrote /tmp/bu-task/shot.webp")
assert m == ["/tmp/bu-task/shot.webp"] assert m == ["/tmp/bu-task/shot.webp"]
def test_plain_words_do_not_match(self): def test_plain_words_do_not_match(self):
assert self._re().findall("no images here, just prose.png-like text /x") == [] assert self._re().findall("no images here, just prose.png-like text /x") == []
class TestSkillHashSymmetry: class TestSkillHashSymmetry:
"""#62310 — disk hash and bundle hash must agree on every OS.""" """#62310 — disk hash and bundle hash must agree on every OS."""
def _make_skill(self, root: Path) -> Path: def _make_skill(self, root: Path) -> Path:
skill = root / "demo-skill" skill = root / "demo-skill"
(skill / "references" / "methods").mkdir(parents=True) (skill / "references" / "methods").mkdir(parents=True)
(skill / "SKILL.md").write_text("---\nname: demo\n---\nbody\n", encoding="utf-8") (skill / "SKILL.md").write_text("---\nname: demo\n---\nbody\n", encoding="utf-8")
(skill / "references" / "methods" / "x.md").write_text("x\n", encoding="utf-8") (skill / "references" / "methods" / "x.md").write_text("x\n", encoding="utf-8")
# Mixed-case name exercises the case-sensitive sort divergence. # Mixed-case name exercises the case-sensitive sort divergence.
(skill / "Zeta.md").write_text("z\n", encoding="utf-8") (skill / "Zeta.md").write_text("z\n", encoding="utf-8")
return skill return skill
def test_disk_and_bundle_hashes_match(self, tmp_path): def test_disk_and_bundle_hashes_match(self, tmp_path):
from tools.skills_guard import content_hash from tools.skills_guard import content_hash
from tools.skills_hub import SkillBundle, bundle_content_hash from tools.skills_hub import SkillBundle, bundle_content_hash
skill = self._make_skill(tmp_path) skill = self._make_skill(tmp_path)
disk = content_hash(skill) disk = content_hash(skill)
files = {} files = {}
for f in skill.rglob("*"): for f in skill.rglob("*"):
if f.is_file(): if f.is_file():
# Native separators — what Windows bundle construction produces. # Native separators — what Windows bundle construction produces.
files[str(f.relative_to(skill))] = f.read_bytes() files[str(f.relative_to(skill))] = f.read_bytes()
bundle = SkillBundle( bundle = SkillBundle(
name="demo-skill", files=files, source="test", name="demo-skill", files=files, source="test",
identifier="test/demo-skill", trust_level="community", identifier="test/demo-skill", trust_level="community",
) )
assert bundle_content_hash(bundle) == disk assert bundle_content_hash(bundle) == disk
def test_backslash_and_posix_keys_hash_identically(self): def test_backslash_and_posix_keys_hash_identically(self):
from tools.skills_hub import SkillBundle, bundle_content_hash from tools.skills_hub import SkillBundle, bundle_content_hash
posix = SkillBundle( posix = SkillBundle(
name="s", name="s",
files={"references/a.md": b"a", "SKILL.md": b"s"}, files={"references/a.md": b"a", "SKILL.md": b"s"},
source="test", source="test",
identifier="t/s", identifier="t/s",
trust_level="community", trust_level="community",
) )
windows = SkillBundle( windows = SkillBundle(
name="s", name="s",
files={"references\\a.md": b"a", "SKILL.md": b"s"}, files={"references\\a.md": b"a", "SKILL.md": b"s"},
source="test", source="test",
identifier="t/s", identifier="t/s",
trust_level="community", trust_level="community",
) )
assert bundle_content_hash(posix) == bundle_content_hash(windows) assert bundle_content_hash(posix) == bundle_content_hash(windows)
class TestLineEndingPreservation: class TestLineEndingPreservation: