mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a cp1251/GBK Windows machine a UTF-8 include list either decodes to mojibake paths (non-ASCII entries silently not copied) or raises UnicodeDecodeError, which the enclosing handler logs at DEBUG and swallows — no include is copied at all, so the worktree starts without .env/keys and the agent breaks invisibly. A Notepad BOM likewise glues to the first include entry on every platform, and to the first .gitignore line, defeating the '.worktrees/' membership check and appending a duplicate entry on each run. Read both files with utf-8-sig + errors=replace, matching the canonical .env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a BOM) and the UTF-8 append this same block already performs on .gitignore. Regression tests exercise the real cli._setup_worktree: the two BOM tests fail without the fix on any platform, the non-ASCII include test additionally reproduces the Windows locale failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6e30aa2a3c
commit
607f647141
2 changed files with 78 additions and 2 deletions
19
cli.py
19
cli.py
|
|
@ -1557,7 +1557,15 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
|
|||
gitignore = Path(repo_root) / ".gitignore"
|
||||
_ignore_entry = ".worktrees/"
|
||||
try:
|
||||
existing = gitignore.read_text() if gitignore.exists() else ""
|
||||
# utf-8-sig: git files are UTF-8 and Notepad prepends a BOM, which
|
||||
# would glue to the first line and defeat the membership check below
|
||||
# (duplicating the entry); the locale default also breaks non-ASCII
|
||||
# patterns on Windows. The append below already writes UTF-8.
|
||||
existing = (
|
||||
gitignore.read_text(encoding="utf-8-sig", errors="replace")
|
||||
if gitignore.exists()
|
||||
else ""
|
||||
)
|
||||
if _ignore_entry not in existing.splitlines():
|
||||
with open(gitignore, "a", encoding="utf-8") as f:
|
||||
if existing and not existing.endswith("\n"):
|
||||
|
|
@ -1607,7 +1615,14 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
|
|||
try:
|
||||
repo_root_resolved = Path(repo_root).resolve()
|
||||
wt_path_resolved = wt_path.resolve()
|
||||
for line in include_file.read_text().splitlines():
|
||||
# utf-8-sig, not the locale default: on a cp1251/GBK Windows
|
||||
# machine a UTF-8 include list either decodes to mojibake paths
|
||||
# (entries silently not copied) or raises UnicodeDecodeError,
|
||||
# which the enclosing handler swallows at DEBUG — no include is
|
||||
# copied at all. A Notepad BOM likewise glued to the first entry.
|
||||
for line in include_file.read_text(
|
||||
encoding="utf-8-sig", errors="replace"
|
||||
).splitlines():
|
||||
entry = line.strip()
|
||||
if not entry or entry.startswith("#"):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -128,3 +128,64 @@ class TestWorktreeIncludeSecurity:
|
|||
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
|
||||
class TestWorktreeIncludeEncoding:
|
||||
"""The include list and .gitignore are UTF-8 files; reading them with the
|
||||
locale default breaks Windows (cp1251/GBK mojibake or UnicodeDecodeError,
|
||||
swallowed at DEBUG so no include is copied), and a Notepad BOM glues to
|
||||
the first line on every platform."""
|
||||
|
||||
def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
(git_repo / ".env").write_text("SECRET=***\n")
|
||||
# Notepad-style UTF-8 with BOM; the BOM must not become part of the
|
||||
# first entry's path.
|
||||
(git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8"))
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
assert (Path(info["path"]) / ".env").exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_non_ascii_worktreeinclude_entry_copied(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
secret = git_repo / "секреты.env"
|
||||
secret.write_text("SECRET=***\n", encoding="utf-8")
|
||||
(git_repo / ".worktreeinclude").write_bytes(
|
||||
"# ключи агента\nсекреты.env\n".encode("utf-8")
|
||||
)
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
assert (Path(info["path"]) / "секреты.env").exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
(git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8"))
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
lines = (
|
||||
(git_repo / ".gitignore")
|
||||
.read_text(encoding="utf-8-sig")
|
||||
.splitlines()
|
||||
)
|
||||
assert lines.count(".worktrees/") == 1, (
|
||||
"BOM glued to the first line must not defeat the membership "
|
||||
f"check and duplicate the entry: {lines!r}"
|
||||
)
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue