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)