This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch feat/oink-core-platform
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit 5edda2d59fc0aa144041d8d028c2dcc28ecfeddb
Author: dark <[email protected]>
AuthorDate: Fri Sep 4 19:46:13 2026 +0800

    fix(versioning): protect sibling worktrees
    
    - enumerate every registered checkout before deleting outputs
    - reject git-marked candidate paths and ancestors
    - fail closed when worktree discovery is unavailable
    - cover sibling and discovery-failure regressions
---
 scripts/test_versioning.py | 49 +++++++++++++++++++++++++++++++++++++++++
 scripts/versioning.py      | 54 ++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 96 insertions(+), 7 deletions(-)

diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py
index f9884269f..f2c2685c6 100644
--- a/scripts/test_versioning.py
+++ b/scripts/test_versioning.py
@@ -1122,6 +1122,55 @@ class VersionUrlTest(unittest.TestCase):
         finally:
             checkout_child.rmdir()
 
+    def test_output_cleanup_rejects_sibling_checkout_marker(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            sibling = Path(temp_name) / "sibling-worktree"
+            sibling.mkdir()
+            (sibling / ".git").write_text(
+                "gitdir: /tmp/fixture.git/worktrees/sibling\n",
+                encoding="utf-8",
+            )
+            with (
+                mock.patch.object(versioning.shutil, "rmtree") as remove,
+                self.assertRaisesRegex(SystemExit, "Git checkout"),
+            ):
+                versioning.prepare_output_directory(sibling, "fixture")
+            remove.assert_not_called()
+
+    def test_output_cleanup_rejects_registered_sibling_worktree(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            sibling = Path(temp_name) / "registered-sibling"
+            sibling.mkdir()
+            with (
+                mock.patch.object(
+                    versioning,
+                    "registered_worktree_roots",
+                    return_value=(versioning.ROOT.resolve(), 
sibling.resolve()),
+                ),
+                mock.patch.object(versioning.shutil, "rmtree") as remove,
+                self.assertRaisesRegex(SystemExit, "Git checkout"),
+            ):
+                versioning.prepare_output_directory(sibling, "fixture")
+            remove.assert_not_called()
+
+    def test_output_cleanup_fails_closed_when_worktrees_cannot_be_enumerated(
+        self,
+    ) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            output = Path(temp_name) / "output"
+            output.mkdir()
+            with (
+                mock.patch.object(
+                    versioning,
+                    "registered_worktree_roots",
+                    side_effect=SystemExit("cannot enumerate protected Git 
worktrees"),
+                ),
+                mock.patch.object(versioning.shutil, "rmtree") as remove,
+                self.assertRaisesRegex(SystemExit, "cannot enumerate"),
+            ):
+                versioning.prepare_output_directory(output, "fixture")
+            remove.assert_not_called()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/scripts/versioning.py b/scripts/versioning.py
index fb2e04d51..1799a4063 100644
--- a/scripts/versioning.py
+++ b/scripts/versioning.py
@@ -266,6 +266,52 @@ def fail(message: str) -> NoReturn:
     raise SystemExit(message)
 
 
+def registered_worktree_roots() -> tuple[pathlib.Path, ...]:
+    """Enumerate every checkout sharing this repository, failing closed."""
+    try:
+        result = subprocess.run(
+            ["git", "worktree", "list", "--porcelain", "-z"],
+            cwd=ROOT,
+            check=False,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+        )
+    except OSError as exc:
+        fail(f"cannot enumerate protected Git worktrees: {exc}")
+    if result.returncode != 0:
+        detail = os.fsdecode(result.stderr).strip()
+        fail(f"cannot enumerate protected Git worktrees: {detail or 
result.returncode}")
+    roots = []
+    for field in result.stdout.split(b"\0"):
+        if not field.startswith(b"worktree "):
+            continue
+        raw = os.fsdecode(field.removeprefix(b"worktree "))
+        path = pathlib.Path(raw)
+        if not raw or not path.is_absolute():
+            fail(f"invalid Git worktree path: {raw!r}")
+        roots.append(path.resolve())
+    if not roots:
+        fail("cannot enumerate protected Git worktrees: no checkout paths")
+    return tuple(roots)
+
+
+def require_output_outside_git_checkouts(output: pathlib.Path, label: str) -> 
None:
+    """Reject registered, prunable, and unregistered checkout paths."""
+    protected = set(registered_worktree_roots())
+    protected.add(ROOT.resolve())
+    for checkout in protected:
+        if (
+            output == checkout
+            or output in checkout.parents
+            or checkout in output.parents
+        ):
+            fail(f"{label} must be outside every Git checkout: {output}")
+    for candidate in (output, *output.parents):
+        marker = candidate / ".git"
+        if marker.exists() or marker.is_symlink():
+            fail(f"{label} must be outside every Git checkout: {output}")
+
+
 def prepare_output_directory(path: pathlib.Path, label: str) -> pathlib.Path:
     raw = path.expanduser()
     if raw.is_symlink():
@@ -280,13 +326,7 @@ def prepare_output_directory(path: pathlib.Path, label: 
str) -> pathlib.Path:
         allowed_roots.add(pathlib.Path(runner_temp).resolve())
     if not any(root != output and root in output.parents for root in 
allowed_roots):
         fail(f"{label} must be below a controlled temporary directory: 
{output}")
-    repository_root = ROOT.resolve()
-    if (
-        output == repository_root
-        or output in repository_root.parents
-        or repository_root in output.parents
-    ):
-        fail(f"{label} must be outside the repository checkout: {output}")
+    require_output_outside_git_checkouts(output, label)
     if output.exists():
         if output.is_symlink() or not output.is_dir():
             fail(f"{label} is not a removable directory: {output}")

Reply via email to