From 607f647141326b7facae729945806cebcf666938 Mon Sep 17 00:00:00 2001 From: solyanviktor-star <233359899+solyanviktor-star@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:55:00 +0300 Subject: [PATCH] fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- cli.py | 19 ++++++++- tests/cli/test_worktree_security.py | 61 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index 6d581f32386a..7df68530ae29 100644 --- a/cli.py +++ b/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 diff --git a/tests/cli/test_worktree_security.py b/tests/cli/test_worktree_security.py index 73a242e0fdaa..bf2c2fa01b5e 100644 --- a/tests/cli/test_worktree_security.py +++ b/tests/cli/test_worktree_security.py @@ -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)