diff --git a/scripts/ci/prune_merged_worktrees.py b/scripts/ci/prune_merged_worktrees.py index 02bde8306c..7cfb2bfe15 100644 --- a/scripts/ci/prune_merged_worktrees.py +++ b/scripts/ci/prune_merged_worktrees.py @@ -205,6 +205,7 @@ import os import re import shutil +import stat import subprocess import sys import traceback @@ -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. @@ -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). @@ -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) diff --git a/scripts/tests/test_prune_merged_worktrees.py b/scripts/tests/test_prune_merged_worktrees.py index 95dd99a309..bc9d648f5c 100644 --- a/scripts/tests/test_prune_merged_worktrees.py +++ b/scripts/tests/test_prune_merged_worktrees.py @@ -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"