Skip to content
Merged
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
46 changes: 43 additions & 3 deletions scripts/ci/prune_merged_worktrees.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
import os
import re
import shutil
import stat
import subprocess
import sys
import traceback
Expand Down Expand Up @@ -1497,6 +1498,42 @@ def list_worktrees() -> list[dict]:
return out


def is_link_like(p: Path) -> bool:
"""True pour un lien symbolique ET pour une jonction Windows (#20007).

`Path.is_symlink()` ne reconnaît que les liens de type *name surrogate* :
une jonction NTFS (reparse point `IO_REPARSE_TAG_MOUNT_POINT`, le geste
courant pour partager un `node_modules` ou un cache entre worktrees) rend
**False** sur les trois voies (`is_symlink`, `os.path.islink`,
`S_ISLNK(st_mode)`) alors qu'elle porte bien
`FILE_ATTRIBUTE_REPARSE_POINT`. Mesuré sur une jonction `mklink /J` :
`st_file_attributes == 0x410` (reparse point + directory).

Conséquence de l'aveuglement : la cible tombait dans la branche
`is_dir()`, où `shutil.rmtree` lève « Cannot call rmtree on a symbolic
link » — un `OSError` avalé par `ignore_errors=True`. L'artefact n'était
donc ni retiré ni listé, et le `git worktree remove` qui suit échouait
sur l'untracked restant : le rapport annonçait REMOVE pour un `--apply`
qui ne pouvait pas aboutir (#14619, même classe que l'incident jonctions
po-2023 c.525).

`os.unlink` retire une jonction sans suivre sa cible (mesuré : la cible
et son contenu survivent), donc le geste de la branche lien convient aux
deux formes — seul le prédicat devait changer.

Le repli `False` couvre les plateformes sans `st_file_attributes`
(POSIX) et un `lstat` impossible : on retombe alors sur le comportement
d'avant, jamais sur une suppression.
"""
if p.is_symlink():
return True
try:
attrs = os.lstat(p).st_file_attributes
except (OSError, AttributeError):
return False
return bool(attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT)


def clean_tolerated_artifacts(wt: WorktreeStatus) -> list[str]:
"""Supprime les SEULS artefacts tolérés du worktree, avant retrait.

Expand All @@ -1510,6 +1547,8 @@ def clean_tolerated_artifacts(wt: WorktreeStatus) -> list[str]:
- seuls les chemins untracked qui matchent la liste tolérée sont visés ;
- chaque cible doit résoudre DANS le worktree (défense en profondeur
contre une entrée porcelain inattendue) ;
- les liens symboliques ET les jonctions Windows partent par `unlink`,
jamais par `rmtree`, qui lève sur les deux (`is_link_like`, #20007) ;
- jamais de `--force` : le retrait reste `git worktree remove` nu. Si un
résidu hors liste survient entre le diagnostic et l'apply, git refuse
et le statut FAILED rend la cause (fail-closed).
Expand All @@ -1529,9 +1568,10 @@ def clean_tolerated_artifacts(wt: WorktreeStatus) -> list[str]:
continue
except OSError:
continue
if target.is_symlink():
# rmtree sur un lien symbolique leve OSError ; unlink est le
# geste correct et ne touche pas la cible.
if is_link_like(target):
# rmtree sur un lien symbolique -- ou sur une jonction Windows --
# leve OSError ; unlink est le geste correct et ne touche pas la
# cible (cf is_link_like, #20007).
try:
target.unlink()
removed.append(rel)
Expand Down
74 changes: 74 additions & 0 deletions scripts/tests/test_prune_merged_worktrees.py
Original file line number Diff line number Diff line change
Expand Up @@ -2562,3 +2562,77 @@ def test_cwd_priority_is_not_memoized(self, tmp_path, monkeypatch):
expected = pmw._ancestor_repo_root(
Path(pmw.__file__).resolve().parent)
assert pmw.current_repo_root() == expected


# ---------------------------------------------------------------------------
# #20007 -- jonctions NTFS : predicat is_link_like + retrait sans suivre la cible
# ---------------------------------------------------------------------------


def _make_junction(link: Path, target: Path) -> None:
"""Cree une jonction NTFS `link -> target`.

`mklink /J` ne demande aucun privilege (contrairement a un symlink, qui
exige Developer Mode ou une elevation) : c'est ce qui rend la jonction
courante pour partager un `node_modules` entre worktrees, et ce qui rend
le defaut #20007 atteignable sans configuration particuliere.
"""
proc = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, text=True, check=False,
encoding="utf-8", errors="replace",
)
if proc.returncode != 0:
pytest.skip(f"mklink indisponible : {(proc.stderr or '').strip()[:120]}")


class TestIsLinkLike:
"""Predicat pur, sans plateforme : il ne doit pas elargir la branche
`unlink` au-dela des liens."""

def test_plain_dir_file_and_missing_are_not_links(self, tmp_path):
d = tmp_path / "plain_dir"
d.mkdir()
f = tmp_path / "plain.txt"
f.write_text("x", encoding="utf-8")
assert pmw.is_link_like(d) is False
assert pmw.is_link_like(f) is False
assert pmw.is_link_like(tmp_path / "absent") is False


@pytest.mark.skipif(sys.platform != "win32", reason="#20007 : jonction NTFS")
class TestWindowsJunctionIsLinkLike:
def test_junction_is_seen_though_is_symlink_is_false(self, tmp_path):
"""Le controle negatif qui fonde #20007 : le predicat d'origine ne
voit PAS une jonction -- et c'est precisement ce qui rendait le
defaut silencieux, la cible tombant dans la branche `is_dir()`."""
target = tmp_path / "real"
target.mkdir()
link = tmp_path / "node_modules"
_make_junction(link, target)

assert link.is_symlink() is False # le predicat d'origine
assert os.path.islink(link) is False # et son equivalent os.path
assert link.is_dir() is True # d'ou la mauvaise branche
assert pmw.is_link_like(link) is True # celui qui repond

def test_clean_removes_junction_and_spares_target(self, tmp_path):
"""Le defaut mesure : `rmtree` leve, `ignore_errors=True` l'avale,
la jonction reste et `removed` la tait -- puis `git worktree remove`
echoue sur l'untracked restant (#14619)."""
target = tmp_path / "real"
target.mkdir()
(target / "marker.txt").write_text("x", encoding="utf-8")
link = tmp_path / "node_modules"
_make_junction(link, target)

wt = _make_status(path=str(tmp_path),
untracked_paths=["node_modules/"])
removed = pmw.clean_tolerated_artifacts(wt)

assert "node_modules/" in removed, \
"la jonction doit etre retiree ET listee (#20007)"
assert not link.exists()
# Garde de surete : on retire le LIEN, jamais la cible.
assert target.is_dir(), "la cible de la jonction doit survivre"
assert (target / "marker.txt").read_text(encoding="utf-8") == "x"
Loading