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}")
