Files
hermes-agent/tests/hermes_cli/test_update_eol_churn.py
ethernet 59624b7ace tests: replace every bare host skipif with platforms()
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.

Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.

Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
  its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
  skip-on-Windows moves onto the 11 methods that had no host mark; the
  11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
  platforms("linux") in favour of the module's "posix" (mkfifo exists on
  macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
  or wider marks removed.
2026-09-21 19:18:15 -04:00

213 lines
7.5 KiB
Python

"""Regression: ``hermes update`` should take a managed checkout off autocrlf=true.
Git for Windows ships ``core.autocrlf=true`` in its system config, which
renormalizes this repo's LF text files to CRLF in the working tree and breaks
``git checkout`` on update. ``install.ps1`` pins ``core.autocrlf=false`` on new
installs, but checkouts created before that landed cannot receive the fix, so
``hermes update`` has to repair them.
The pin and the cleanup are one operation: under ``autocrlf=true`` git compares
normalized content, so the CRLF tree reads clean and pinning alone would expose
the whole tree as modified. These tests pin down that coupling.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from hermes_cli.update_cmd import _normalize_managed_eol
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
GIT_CMD = ["git"]
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
)
def _managed_repo(tmp_path: Path, files: dict[str, bytes]) -> Path:
"""A checkout in the broken state: LF in the index, CRLF in the worktree,
``core.autocrlf=true`` still set — i.e. what Git for Windows leaves behind.
The CRLF is written by git's own checkout rather than by hand, which is how a
real managed install gets there. It matters: git's stat cache then records the
CRLF size, so the churn reads *clean* under ``autocrlf=true`` and is only
visible once the pin is evaluated.
"""
repo = tmp_path / "managed"
repo.mkdir()
_git(repo, "init")
for name, body in files.items():
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(body)
_git(repo, "-c", "core.autocrlf=false", "add", "-A")
_git(repo, "commit", "-m", "init")
_git(repo, "config", "core.autocrlf", "true")
for name in files:
(repo / name).unlink()
_git(repo, "checkout", "--", ".")
# Deterministic dirtiness: whether `git diff` content-checks an entry (and
# so sees the CRLF churn) or trusts the stat cache depends on racy-git
# detection — entries whose mtime equals the index timestamp get content-
# compared, later ones read clean. On a fast runner a large checkout
# straddles that boundary nondeterministically (CI flake: 92/661 of 1200
# dirty). Bump every worktree mtime past the index write so ALL entries
# are stat-stale and git must content-compare each one.
import os as _os
import time as _time
bumped = _time.time() + 5
for name in files:
_os.utime(repo / name, (bumped, bumped))
return repo
def _dirty(repo: Path) -> set[str]:
out = subprocess.run(
["git", "-c", "core.autocrlf=false", "diff", "--name-only"],
cwd=repo,
capture_output=True,
text=True,
check=True,
)
return {line for line in out.stdout.splitlines() if line}
def _autocrlf(repo: Path) -> str:
out = subprocess.run(
["git", "config", "--local", "--get", "core.autocrlf"],
cwd=repo,
capture_output=True,
text=True,
)
return out.stdout.strip()
def test_churn_invisible_under_autocrlf_true_is_still_found(tmp_path: Path) -> None:
"""The reason the pin and the cleanup are one operation.
A managed install's CRLF churn reads clean while ``autocrlf=true`` is set, so
an implementation that only looked at ``git status`` would pin the config and
hand the update a whole-tree autostash. Evaluating the tree as it would look
pinned is what makes the churn visible in time to clear it.
"""
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\ny = 2\n"})
as_git_sees_it = subprocess.run(
["git", "status", "--porcelain"], cwd=repo, capture_output=True, text=True, check=True
)
assert as_git_sees_it.stdout.strip() == ""
assert b"\r\n" in (repo / "a.py").read_bytes()
_normalize_managed_eol(GIT_CMD, repo)
assert _dirty(repo) == set()
assert b"\r\n" not in (repo / "a.py").read_bytes()
assert _autocrlf(repo) == "false"
def test_churn_is_cleared_and_the_pin_is_persisted(tmp_path: Path) -> None:
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\n", "b.md": b"# Title\n\nbody\n"})
_normalize_managed_eol(GIT_CMD, repo)
assert _dirty(repo) == set()
assert b"\r\n" not in (repo / "a.py").read_bytes()
assert _autocrlf(repo) == "false"
def test_real_edits_survive_even_when_line_endings_also_flipped(tmp_path: Path) -> None:
repo = _managed_repo(tmp_path, {"churn.py": b"y = 2\n", "both.py": b"z = 3\n"})
# A genuine edit that ALSO got renormalized must not be discarded.
(repo / "both.py").write_bytes(b"z = 3\r\nz += 1\r\n")
_normalize_managed_eol(GIT_CMD, repo)
assert _dirty(repo) == {"both.py"}
assert (repo / "both.py").read_bytes() == b"z = 3\r\nz += 1\r\n"
assert _autocrlf(repo) == "false"
def test_pin_alone_is_written_when_there_is_no_churn(tmp_path: Path) -> None:
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\n"})
(repo / "a.py").write_bytes(b"x = 1\n")
_normalize_managed_eol(GIT_CMD, repo)
assert _dirty(repo) == set()
assert _autocrlf(repo) == "false"
@pytest.mark.platforms("posix") # shim needs a POSIX shell
def test_pin_is_withheld_when_the_churn_cannot_be_cleared(tmp_path: Path) -> None:
"""If normalization can't finish, the checkout is left as found — pinning
anyway would surface churn we failed to clear."""
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\n"})
# Real git everywhere except the restore, which fails. Stands in for any
# reason it cannot finish: a git too old for --pathspec-from-file, an
# unwritable working tree, a file locked by a running process.
shim = tmp_path / "git-no-checkout"
shim.write_text(
'#!/bin/sh\nfor a in "$@"; do [ "$a" = checkout ] && exit 1; done\nexec git "$@"\n'
)
shim.chmod(0o755)
_normalize_managed_eol([str(shim)], repo)
assert _autocrlf(repo) == "true"
assert b"\r\n" in (repo / "a.py").read_bytes()
def test_already_pinned_checkout_is_untouched(tmp_path: Path) -> None:
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\n"})
_git(repo, "config", "core.autocrlf", "false")
_normalize_managed_eol(GIT_CMD, repo)
# Nothing to repair from this function's perspective: autocrlf is not true,
# so it must not start restoring files behind the user's back.
assert _dirty(repo) == {"a.py"}
def test_autocrlf_input_is_left_alone(tmp_path: Path) -> None:
repo = _managed_repo(tmp_path, {"a.py": b"x = 1\n"})
_git(repo, "config", "core.autocrlf", "input")
_normalize_managed_eol(GIT_CMD, repo)
assert _autocrlf(repo) == "input"
def test_churn_across_more_files_than_fit_in_one_argv(tmp_path: Path) -> None:
"""The pathspec goes over stdin, so a fully renormalized tree fits.
Passing thousands of paths as arguments overflows the Windows command-line
limit; this asserts the batch is not argv-bound.
"""
files = {f"pkg/mod_{i:04d}.py": f"VALUE = {i}\n".encode() for i in range(1200)}
repo = _managed_repo(tmp_path, files)
assert len(_dirty(repo)) == len(files)
_normalize_managed_eol(GIT_CMD, repo)
assert _dirty(repo) == set()
assert _autocrlf(repo) == "false"
def test_non_repo_is_ignored(tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
_normalize_managed_eol(GIT_CMD, tmp_path)
assert capsys.readouterr().out == ""