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:
24
.gitattributes
vendored
24
.gitattributes
vendored
@@ -8,3 +8,27 @@ web/package-lock.json linguist-generated=true
|
||||
Dockerfile text eol=lf
|
||||
*.dockerfile 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
|
||||
|
||||
@@ -1,179 +1,179 @@
|
||||
"""Windows agent-loop correctness regressions.
|
||||
|
||||
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
|
||||
Windows unpleasant. Each test names the issue it pins.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli._subprocess_compat import split_command_line
|
||||
|
||||
|
||||
class TestSplitCommandLine:
|
||||
"""#83934 / #78293 — backslashes in Windows paths must survive splitting."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_windows_path_backslashes_preserved(self):
|
||||
argv = split_command_line(r"sessions export C:\Users\me\Desktop\out.jsonl")
|
||||
assert argv == ["sessions", "export", r"C:\Users\me\Desktop\out.jsonl"]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_quoted_path_with_spaces(self):
|
||||
argv = split_command_line(r'run "C:\Program Files\App\tool.exe" --flag')
|
||||
assert argv == ["run", r"C:\Program Files\App\tool.exe", "--flag"]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_bare_hook_command_path(self):
|
||||
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"
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_posix_behavior_unchanged(self):
|
||||
assert split_command_line("echo 'a b' c") == ["echo", "a b", "c"]
|
||||
|
||||
def test_unbalanced_quote_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
split_command_line('run "unterminated')
|
||||
|
||||
|
||||
class TestShellHooksWindowsPaths:
|
||||
"""#78293 — hook script paths with backslashes resolve correctly."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_command_script_path_keeps_backslashes(self):
|
||||
from agent.shell_hooks import _command_script_path
|
||||
|
||||
path = _command_script_path(r"C:\hooks\guard.py --strict")
|
||||
assert path == r"C:\hooks\guard.py"
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_script_is_executable_finds_real_file(self, tmp_path):
|
||||
from agent.shell_hooks import script_is_executable
|
||||
|
||||
script = tmp_path / "hook.py"
|
||||
script.write_text("print('ok')\n", encoding="utf-8")
|
||||
#
|
||||
|
||||
assert script_is_executable(f'python "{script}"') or script_is_executable(
|
||||
f"python {script}"
|
||||
)
|
||||
|
||||
|
||||
class TestWindowsMarketingVersion:
|
||||
"""#51755 — Windows 11 must not be reported as Windows 10."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_matches_build_number(self):
|
||||
from agent.prompt_builder import _windows_marketing_version
|
||||
|
||||
build = sys.getwindowsversion().build
|
||||
expected = "11" if build >= 22000 else "10"
|
||||
assert _windows_marketing_version() == expected
|
||||
|
||||
def test_fallback_on_lookup_failure(self, monkeypatch):
|
||||
import agent.prompt_builder as pb
|
||||
|
||||
if sys.platform == "win32":
|
||||
monkeypatch.delattr(sys, "getwindowsversion")
|
||||
assert isinstance(pb._windows_marketing_version(), str)
|
||||
|
||||
|
||||
class TestAutocompleteDevicePaths:
|
||||
"""#42016 — relpath ValueError on device paths must not escape."""
|
||||
|
||||
def test_relpath_valueerror_pattern(self):
|
||||
# The guarded pattern in _get_project_files: a ValueError from
|
||||
# os.path.relpath (different mount) is skipped, not raised.
|
||||
bad = "\\\\.\\nul" if sys.platform == "win32" else "/dev/null"
|
||||
cwd = os.getcwd()
|
||||
files = []
|
||||
for p in [bad, os.path.join(cwd, "real.txt")]:
|
||||
try:
|
||||
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
|
||||
except ValueError:
|
||||
continue
|
||||
files.append(rel)
|
||||
assert "real.txt" in files
|
||||
|
||||
|
||||
class TestBrowserScreenshotPathRegex:
|
||||
"""#83884 — Windows drive-letter screenshot paths must be detected."""
|
||||
|
||||
def _re(self):
|
||||
from tools.browser_use_cli import _IMAGE_PATH_RE
|
||||
|
||||
return _IMAGE_PATH_RE
|
||||
|
||||
def test_windows_backslash_path(self):
|
||||
m = self._re().findall(r"Saved screenshot to C:\Users\u\shots\page.png done")
|
||||
assert m == [r"C:\Users\u\shots\page.png"]
|
||||
|
||||
def test_windows_forward_slash_path(self):
|
||||
m = self._re().findall("shot: C:/Users/u/shots/page.jpeg")
|
||||
assert m == ["C:/Users/u/shots/page.jpeg"]
|
||||
|
||||
def test_posix_path_still_matches(self):
|
||||
m = self._re().findall("wrote /tmp/bu-task/shot.webp")
|
||||
assert m == ["/tmp/bu-task/shot.webp"]
|
||||
|
||||
def test_plain_words_do_not_match(self):
|
||||
assert self._re().findall("no images here, just prose.png-like text /x") == []
|
||||
|
||||
|
||||
class TestSkillHashSymmetry:
|
||||
"""#62310 — disk hash and bundle hash must agree on every OS."""
|
||||
|
||||
def _make_skill(self, root: Path) -> Path:
|
||||
skill = root / "demo-skill"
|
||||
(skill / "references" / "methods").mkdir(parents=True)
|
||||
(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")
|
||||
# Mixed-case name exercises the case-sensitive sort divergence.
|
||||
(skill / "Zeta.md").write_text("z\n", encoding="utf-8")
|
||||
return skill
|
||||
|
||||
def test_disk_and_bundle_hashes_match(self, tmp_path):
|
||||
from tools.skills_guard import content_hash
|
||||
from tools.skills_hub import SkillBundle, bundle_content_hash
|
||||
|
||||
skill = self._make_skill(tmp_path)
|
||||
disk = content_hash(skill)
|
||||
|
||||
files = {}
|
||||
for f in skill.rglob("*"):
|
||||
if f.is_file():
|
||||
# Native separators — what Windows bundle construction produces.
|
||||
files[str(f.relative_to(skill))] = f.read_bytes()
|
||||
bundle = SkillBundle(
|
||||
name="demo-skill", files=files, source="test",
|
||||
identifier="test/demo-skill", trust_level="community",
|
||||
)
|
||||
assert bundle_content_hash(bundle) == disk
|
||||
|
||||
def test_backslash_and_posix_keys_hash_identically(self):
|
||||
from tools.skills_hub import SkillBundle, bundle_content_hash
|
||||
|
||||
posix = SkillBundle(
|
||||
name="s",
|
||||
files={"references/a.md": b"a", "SKILL.md": b"s"},
|
||||
source="test",
|
||||
identifier="t/s",
|
||||
trust_level="community",
|
||||
)
|
||||
windows = SkillBundle(
|
||||
name="s",
|
||||
files={"references\\a.md": b"a", "SKILL.md": b"s"},
|
||||
source="test",
|
||||
identifier="t/s",
|
||||
trust_level="community",
|
||||
)
|
||||
assert bundle_content_hash(posix) == bundle_content_hash(windows)
|
||||
"""Windows agent-loop correctness regressions.
|
||||
|
||||
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
|
||||
Windows unpleasant. Each test names the issue it pins.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli._subprocess_compat import split_command_line
|
||||
|
||||
|
||||
class TestSplitCommandLine:
|
||||
"""#83934 / #78293 — backslashes in Windows paths must survive splitting."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_windows_path_backslashes_preserved(self):
|
||||
argv = split_command_line(r"sessions export C:\Users\me\Desktop\out.jsonl")
|
||||
assert argv == ["sessions", "export", r"C:\Users\me\Desktop\out.jsonl"]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_quoted_path_with_spaces(self):
|
||||
argv = split_command_line(r'run "C:\Program Files\App\tool.exe" --flag')
|
||||
assert argv == ["run", r"C:\Program Files\App\tool.exe", "--flag"]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_bare_hook_command_path(self):
|
||||
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"
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_posix_behavior_unchanged(self):
|
||||
assert split_command_line("echo 'a b' c") == ["echo", "a b", "c"]
|
||||
|
||||
def test_unbalanced_quote_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
split_command_line('run "unterminated')
|
||||
|
||||
|
||||
class TestShellHooksWindowsPaths:
|
||||
"""#78293 — hook script paths with backslashes resolve correctly."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_command_script_path_keeps_backslashes(self):
|
||||
from agent.shell_hooks import _command_script_path
|
||||
|
||||
path = _command_script_path(r"C:\hooks\guard.py --strict")
|
||||
assert path == r"C:\hooks\guard.py"
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_script_is_executable_finds_real_file(self, tmp_path):
|
||||
from agent.shell_hooks import script_is_executable
|
||||
|
||||
script = tmp_path / "hook.py"
|
||||
script.write_text("print('ok')\n", encoding="utf-8")
|
||||
#
|
||||
|
||||
assert script_is_executable(f'python "{script}"') or script_is_executable(
|
||||
f"python {script}"
|
||||
)
|
||||
|
||||
|
||||
class TestWindowsMarketingVersion:
|
||||
"""#51755 — Windows 11 must not be reported as Windows 10."""
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_matches_build_number(self):
|
||||
from agent.prompt_builder import _windows_marketing_version
|
||||
|
||||
build = sys.getwindowsversion().build
|
||||
expected = "11" if build >= 22000 else "10"
|
||||
assert _windows_marketing_version() == expected
|
||||
|
||||
def test_fallback_on_lookup_failure(self, monkeypatch):
|
||||
import agent.prompt_builder as pb
|
||||
|
||||
if sys.platform == "win32":
|
||||
monkeypatch.delattr(sys, "getwindowsversion")
|
||||
assert isinstance(pb._windows_marketing_version(), str)
|
||||
|
||||
|
||||
class TestAutocompleteDevicePaths:
|
||||
"""#42016 — relpath ValueError on device paths must not escape."""
|
||||
|
||||
def test_relpath_valueerror_pattern(self):
|
||||
# The guarded pattern in _get_project_files: a ValueError from
|
||||
# os.path.relpath (different mount) is skipped, not raised.
|
||||
bad = "\\\\.\\nul" if sys.platform == "win32" else "/dev/null"
|
||||
cwd = os.getcwd()
|
||||
files = []
|
||||
for p in [bad, os.path.join(cwd, "real.txt")]:
|
||||
try:
|
||||
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
|
||||
except ValueError:
|
||||
continue
|
||||
files.append(rel)
|
||||
assert "real.txt" in files
|
||||
|
||||
|
||||
class TestBrowserScreenshotPathRegex:
|
||||
"""#83884 — Windows drive-letter screenshot paths must be detected."""
|
||||
|
||||
def _re(self):
|
||||
from tools.browser_use_cli import _IMAGE_PATH_RE
|
||||
|
||||
return _IMAGE_PATH_RE
|
||||
|
||||
def test_windows_backslash_path(self):
|
||||
m = self._re().findall(r"Saved screenshot to C:\Users\u\shots\page.png done")
|
||||
assert m == [r"C:\Users\u\shots\page.png"]
|
||||
|
||||
def test_windows_forward_slash_path(self):
|
||||
m = self._re().findall("shot: C:/Users/u/shots/page.jpeg")
|
||||
assert m == ["C:/Users/u/shots/page.jpeg"]
|
||||
|
||||
def test_posix_path_still_matches(self):
|
||||
m = self._re().findall("wrote /tmp/bu-task/shot.webp")
|
||||
assert m == ["/tmp/bu-task/shot.webp"]
|
||||
|
||||
def test_plain_words_do_not_match(self):
|
||||
assert self._re().findall("no images here, just prose.png-like text /x") == []
|
||||
|
||||
|
||||
class TestSkillHashSymmetry:
|
||||
"""#62310 — disk hash and bundle hash must agree on every OS."""
|
||||
|
||||
def _make_skill(self, root: Path) -> Path:
|
||||
skill = root / "demo-skill"
|
||||
(skill / "references" / "methods").mkdir(parents=True)
|
||||
(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")
|
||||
# Mixed-case name exercises the case-sensitive sort divergence.
|
||||
(skill / "Zeta.md").write_text("z\n", encoding="utf-8")
|
||||
return skill
|
||||
|
||||
def test_disk_and_bundle_hashes_match(self, tmp_path):
|
||||
from tools.skills_guard import content_hash
|
||||
from tools.skills_hub import SkillBundle, bundle_content_hash
|
||||
|
||||
skill = self._make_skill(tmp_path)
|
||||
disk = content_hash(skill)
|
||||
|
||||
files = {}
|
||||
for f in skill.rglob("*"):
|
||||
if f.is_file():
|
||||
# Native separators — what Windows bundle construction produces.
|
||||
files[str(f.relative_to(skill))] = f.read_bytes()
|
||||
bundle = SkillBundle(
|
||||
name="demo-skill", files=files, source="test",
|
||||
identifier="test/demo-skill", trust_level="community",
|
||||
)
|
||||
assert bundle_content_hash(bundle) == disk
|
||||
|
||||
def test_backslash_and_posix_keys_hash_identically(self):
|
||||
from tools.skills_hub import SkillBundle, bundle_content_hash
|
||||
|
||||
posix = SkillBundle(
|
||||
name="s",
|
||||
files={"references/a.md": b"a", "SKILL.md": b"s"},
|
||||
source="test",
|
||||
identifier="t/s",
|
||||
trust_level="community",
|
||||
)
|
||||
windows = SkillBundle(
|
||||
name="s",
|
||||
files={"references\\a.md": b"a", "SKILL.md": b"s"},
|
||||
source="test",
|
||||
identifier="t/s",
|
||||
trust_level="community",
|
||||
)
|
||||
assert bundle_content_hash(posix) == bundle_content_hash(windows)
|
||||
|
||||
|
||||
class TestLineEndingPreservation:
|
||||
|
||||
Reference in New Issue
Block a user