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:
solyanviktor-star 2026-07-21 19:55:00 +03:00 committed by Teknium
parent 6e30aa2a3c
commit 607f647141
2 changed files with 78 additions and 2 deletions

19
cli.py
View file

@ -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

View file

@ -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)