Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,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"):
Expand Down Expand Up @@ -1412,7 +1420,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
Expand Down
61 changes: 61 additions & 0 deletions tests/cli/test_worktree_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,64 @@ def test_allows_valid_directory_include(self, git_repo):
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