From a2a9614d225dfa7199862eecc23adaedd9c6d077 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 22 Feb 2026 14:08:50 +0000 Subject: [PATCH 1/2] fix --- src/holoviz_mcp/holoviews_mcp/server.py | 4 +- src/holoviz_mcp/holoviz_mcp/data.py | 52 +++-- src/holoviz_mcp/holoviz_mcp/server.py | 16 +- src/holoviz_mcp/hvplot_mcp/server.py | 6 +- src/holoviz_mcp/panel_mcp/server.py | 10 +- tests/docs_mcp/test_data.py | 254 ++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 35 deletions(-) diff --git a/src/holoviz_mcp/holoviews_mcp/server.py b/src/holoviz_mcp/holoviews_mcp/server.py index 67e5f0f..a17583b 100644 --- a/src/holoviz_mcp/holoviews_mcp/server.py +++ b/src/holoviz_mcp/holoviews_mcp/server.py @@ -30,7 +30,7 @@ ) -@mcp.tool +@mcp.tool() async def list_elements(ctx: Context) -> list[str]: """ List all available HoloViews visualization elements. @@ -50,7 +50,7 @@ async def list_elements(ctx: Context) -> list[str]: return sorted(elements_list) -@mcp.tool +@mcp.tool() async def get_docstring(ctx: Context, element: str, backend: Literal["matplotlib", "bokeh", "plotly"] = "bokeh") -> str: """ Get the hvPlot docstring for a specific plot type, including available options and usage details. diff --git a/src/holoviz_mcp/holoviz_mcp/data.py b/src/holoviz_mcp/holoviz_mcp/data.py index f2bd99b..a079505 100644 --- a/src/holoviz_mcp/holoviz_mcp/data.py +++ b/src/holoviz_mcp/holoviz_mcp/data.py @@ -1258,25 +1258,43 @@ def _clone_or_update_repo_sync(self, repo_name: str, repo_config: "GitRepository try: if repo_path.exists(): - logger.info(f"Updating {repo_name} repository at {repo_path}...") repo = git.Repo(repo_path) - repo.remotes.origin.pull() - else: - logger.info(f"Cloning {repo_name} repository to {repo_path}...") - clone_kwargs: dict[str, Any] = {"depth": 1} - - if repo_config.branch: - clone_kwargs["branch"] = repo_config.branch - elif repo_config.tag: - clone_kwargs["branch"] = repo_config.tag - elif repo_config.commit: - git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs) - repo = git.Repo(repo_path) - repo.git.checkout(repo_config.commit) + expected_ref = repo_config.branch or repo_config.tag + + if expected_ref: + try: + current_ref = repo.active_branch.name + except TypeError: + current_ref = None # Detached HEAD (e.g. tag/commit checkout) + + if current_ref != expected_ref: + logger.info(f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. " "Deleting and re-cloning...") + shutil.rmtree(repo_path) + # Fall through to clone below + else: + logger.info(f"Updating {repo_name} repository at {repo_path}...") + repo.remotes.origin.pull() + return repo_path + else: + logger.info(f"Updating {repo_name} repository at {repo_path}...") + repo.remotes.origin.pull() return repo_path + # Repo does not exist (fresh or was just deleted above) + logger.info(f"Cloning {repo_name} repository to {repo_path}...") + clone_kwargs: dict[str, Any] = {"depth": 1} + + if repo_config.branch: + clone_kwargs["branch"] = repo_config.branch + elif repo_config.tag: + clone_kwargs["branch"] = repo_config.tag + elif repo_config.commit: git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs) + repo = git.Repo(repo_path) + repo.git.checkout(repo_config.commit) + return repo_path + git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs) return repo_path except Exception as e: logger.warning(f"Failed to clone/update {repo_name}: {e}") @@ -1343,7 +1361,7 @@ def _extract_docs_from_repo_sync(self, repo_path: Path, project: str, old_hashes reference_count = sum(1 for doc in docs if doc["is_reference"]) regular_count = len(docs) - reference_count if skipped_count: - logger.info(f" {project}: {len(docs)} changed + {skipped_count} unchanged " f"({regular_count} regular, {reference_count} reference guides)") + logger.info(f" {project}: {len(docs)} changed + {skipped_count} unchanged ({regular_count} regular, {reference_count} reference guides)") else: logger.info(f" {project}: {len(docs)} total documents ({regular_count} regular, {reference_count} reference guides)") return {"docs": docs, "skipped_hashes": skipped_hashes} @@ -1676,7 +1694,7 @@ async def index_documentation( clone_extract_elapsed = time.monotonic() - overall_t0 await log_info( - f"Clone + extract completed in {clone_extract_elapsed:.1f}s " f"({len(all_docs)} to index, {len(all_skipped_hashes)} unchanged)", + f"Clone + extract completed in {clone_extract_elapsed:.1f}s ({len(all_docs)} to index, {len(all_skipped_hashes)} unchanged)", ctx, ) @@ -1702,7 +1720,7 @@ async def index_documentation( docs_to_index = all_docs # all are new or changed (unchanged were skipped by workers) await log_info( - f"Incremental: {len(new_docs)} new, {len(changed_docs)} changed, " f"{len(all_skipped_hashes)} unchanged, {len(deleted_doc_ids)} deleted", + f"Incremental: {len(new_docs)} new, {len(changed_docs)} changed, {len(all_skipped_hashes)} unchanged, {len(deleted_doc_ids)} deleted", ctx, ) diff --git a/src/holoviz_mcp/holoviz_mcp/server.py b/src/holoviz_mcp/holoviz_mcp/server.py index f29c04e..3a595ee 100644 --- a/src/holoviz_mcp/holoviz_mcp/server.py +++ b/src/holoviz_mcp/holoviz_mcp/server.py @@ -156,7 +156,7 @@ async def app_lifespan(server: FastMCP): ) -@mcp.tool +@mcp.tool() def get_skill(name: str) -> str: """Get the specified skill for usage with LLMs. @@ -181,7 +181,7 @@ def get_skill(name: str) -> str: return _get_skill(name) -@mcp.tool +@mcp.tool() def list_skills() -> list[str]: """List all available skills. @@ -195,7 +195,7 @@ def list_skills() -> list[str]: return _list_skills() -@mcp.tool +@mcp.tool() async def get_reference_guide( component: str, project: str | None = None, @@ -237,7 +237,7 @@ async def get_reference_guide( return await indexer.search_get_reference_guide(component, project, content, ctx=ctx) -@mcp.tool +@mcp.tool() async def list_projects() -> list[str]: """List all HoloViz and user-defined projects with indexed documentation. @@ -253,7 +253,7 @@ async def list_projects() -> list[str]: return await indexer.list_projects() -@mcp.tool +@mcp.tool() async def get_document(path: str, project: str, ctx: Context) -> Document: """Retrieve a specific document by path and project. @@ -271,7 +271,7 @@ async def get_document(path: str, project: str, ctx: Context) -> Document: return await indexer.get_document(path, project, ctx=ctx) -@mcp.tool +@mcp.tool() async def search( query: str, project: str | None = None, @@ -349,7 +349,7 @@ async def search( return await indexer.search(query, project, content, max_results, max_content_chars, ctx=ctx) -@mcp.tool(enabled=False) +# @mcp.tool(enabled=False) async def update_index(ctx: Context) -> str: """Update the documentation index by re-cloning repositories and re-indexing content. @@ -380,7 +380,7 @@ async def update_index(ctx: Context) -> str: return error_msg -@mcp.tool +@mcp.tool() async def display( code: str, name: str = "", diff --git a/src/holoviz_mcp/hvplot_mcp/server.py b/src/holoviz_mcp/hvplot_mcp/server.py index 40d764b..9ecba60 100644 --- a/src/holoviz_mcp/hvplot_mcp/server.py +++ b/src/holoviz_mcp/hvplot_mcp/server.py @@ -46,7 +46,7 @@ def _help( return doc, sig -@mcp.tool +@mcp.tool() async def list_plot_types(ctx: Context) -> list[str]: """ List all available hvPlot plot types supported in the current environment. @@ -75,7 +75,7 @@ async def list_plot_types(ctx: Context) -> list[str]: return sorted(HoloViewsConverter._kind_mapping) -@mcp.tool +@mcp.tool() async def get_docstring( ctx: Context, plot_type: str, docstring: bool = True, generic: bool = True, style: Union[Literal["matplotlib", "bokeh", "plotly"], bool] = True ) -> str: @@ -111,7 +111,7 @@ async def get_docstring( return doc -@mcp.tool +@mcp.tool() async def get_signature(ctx: Context, plot_type: str, style: Union[Literal["matplotlib", "bokeh", "plotly"], bool] = True) -> str: """ Get the function signature for a specific hvPlot plot type. diff --git a/src/holoviz_mcp/panel_mcp/server.py b/src/holoviz_mcp/panel_mcp/server.py index a8c490c..77030bc 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -126,7 +126,7 @@ async def _get_all_components(ctx: Context) -> list[ComponentDetails]: return COMPONENTS -@mcp.tool +@mcp.tool() async def list_packages(ctx: Context) -> list[str]: """ List all installed packages that provide Panel UI components. @@ -158,7 +158,7 @@ async def list_packages(ctx: Context) -> list[str]: return sorted(set(component.package for component in await _get_all_components(ctx))) -@mcp.tool +@mcp.tool() async def search_components(ctx: Context, query: str, package: str | None = None, limit: int = 10) -> list[ComponentSummarySearchResult]: """ Search for Panel components by search query and optional package filter. @@ -265,7 +265,7 @@ async def _get_component(ctx: Context, name: str | None = None, module_path: str return components_list -@mcp.tool +@mcp.tool() async def list_components(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> list[ComponentSummary]: """ Get a summary list of Panel components without detailed docstring and parameter information. @@ -322,7 +322,7 @@ async def list_components(ctx: Context, name: str | None = None, module_path: st return components_list -@mcp.tool +@mcp.tool() async def get_component(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> ComponentDetails: """ Get complete details about a single Panel component including docstring and parameters. @@ -383,7 +383,7 @@ async def get_component(ctx: Context, name: str | None = None, module_path: str return component -@mcp.tool +@mcp.tool() async def get_component_parameters(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> dict[str, ParameterInfo]: """ Get detailed parameter information for a single Panel component. diff --git a/tests/docs_mcp/test_data.py b/tests/docs_mcp/test_data.py index 2c250f5..68ea6c1 100644 --- a/tests/docs_mcp/test_data.py +++ b/tests/docs_mcp/test_data.py @@ -1862,3 +1862,257 @@ async def test_projects_filter_invalid_name(tmp_path): with patch.object(indexer.config, "repositories", repo_configs): with pytest.raises(ValueError, match="nonexistent"): await indexer.index_documentation(projects=["nonexistent"]) + + +class TestBranchHandling: + """Tests verifying that the configured branch is respected during clone and update.""" + + def _make_indexer(self, tmp_path): + from chromadb.api.shared_system_client import SharedSystemClient + + SharedSystemClient.clear_system_cache() + return DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=tmp_path / "chroma") + + def test_clone_passes_configured_branch_to_git(self, tmp_path): + """Fresh clone: configured branch is passed to git.Repo.clone_from.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/my-project.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + with patch.object(git.Repo, "clone_from", return_value=MagicMock()) as mock_clone_from: + indexer._clone_or_update_repo_sync("my-project", repo_config) + + mock_clone_from.assert_called_once() + _, kwargs = mock_clone_from.call_args + assert kwargs.get("branch") == "feature/my-branch" + + def test_clone_without_branch_config_uses_no_branch_kwarg(self, tmp_path): + """Fresh clone with no branch configured: no branch kwarg is passed.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + folders=["doc"], + ) + + with patch.object(git.Repo, "clone_from", return_value=MagicMock()) as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_clone_from.assert_called_once() + _, kwargs = mock_clone_from.call_args + assert "branch" not in kwargs + + def test_source_url_uses_configured_branch(self): + """_to_source_url returns URL with the configured branch.""" + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake-org/my-project.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + url = DocumentationIndexer._to_source_url(Path("docs/guide.md"), repo_config) + + assert url == "https://github.com/fake-org/my-project/blob/feature/my-branch/docs/guide.md" + + def test_update_existing_repo_on_correct_branch_pulls(self, tmp_path): + """Existing repo on the correct branch: pull is called, no re-clone.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + mock_repo = MagicMock() + mock_repo.active_branch.name = "feature/my-branch" + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch.object(git.Repo, "clone_from") as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_clone_from.assert_not_called() + mock_repo.remotes.origin.pull.assert_called_once() + + def test_stale_branch_triggers_reclone(self, tmp_path): + """Existing repo on wrong branch: dir is deleted and re-cloned on the correct branch.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + mock_repo = MagicMock() + mock_repo.active_branch.name = "main" + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree") as mock_rmtree: + with patch.object(git.Repo, "clone_from", return_value=MagicMock()) as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_rmtree.assert_called_once_with(repo_dir) + mock_clone_from.assert_called_once() + _, kwargs = mock_clone_from.call_args + assert kwargs.get("branch") == "feature/my-branch" + + def test_update_repo_with_no_branch_config_just_pulls(self, tmp_path): + """Existing repo with no branch configured: pull is called, no re-clone, no rmtree.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + mock_repo = MagicMock() + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree") as mock_rmtree: + with patch.object(git.Repo, "clone_from") as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_rmtree.assert_not_called() + mock_clone_from.assert_not_called() + mock_repo.remotes.origin.pull.assert_called_once() + + def test_git_repo_constructor_failure_returns_none(self, tmp_path): + """If git.Repo() raises unexpectedly, the outer except catches it and None is returned.""" + from unittest.mock import patch + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", side_effect=RuntimeError("corrupted repo")): + result = indexer._clone_or_update_repo_sync("repo", repo_config) + + assert result is None + + def test_tag_config_used_as_branch_kwarg_on_clone(self, tmp_path): + """Fresh clone with tag configured: tag is passed as branch kwarg to clone_from.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + tag="v1.2.3", + folders=["doc"], + ) + + with patch.object(git.Repo, "clone_from", return_value=MagicMock()) as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_clone_from.assert_called_once() + _, kwargs = mock_clone_from.call_args + assert kwargs.get("branch") == "v1.2.3" + + def test_stale_tag_triggers_reclone(self, tmp_path): + """Existing repo on wrong tag: dir is deleted and re-cloned on the correct tag.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + tag="v1.2.3", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + mock_repo = MagicMock() + mock_repo.active_branch.name = "v0.9.0" + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree") as mock_rmtree: + with patch.object(git.Repo, "clone_from", return_value=MagicMock()) as mock_clone_from: + indexer._clone_or_update_repo_sync("repo", repo_config) + + mock_rmtree.assert_called_once_with(repo_dir) + mock_clone_from.assert_called_once() + _, kwargs = mock_clone_from.call_args + assert kwargs.get("branch") == "v1.2.3" + + def test_rmtree_succeeds_but_clone_fails_returns_none(self, tmp_path): + """rmtree succeeds but clone_from raises: exception is caught and None is returned.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + import git + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + branch="feature/my-branch", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + mock_repo = MagicMock() + mock_repo.active_branch.name = "main" + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree"): + with patch.object(git.Repo, "clone_from", side_effect=Exception("network error")): + result = indexer._clone_or_update_repo_sync("repo", repo_config) + + assert result is None From 1b446727b6b2d1e8a38ee01b8ba0a29783f595d6 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 22 Feb 2026 18:39:56 +0000 Subject: [PATCH 2/2] copilot feedback --- src/holoviz_mcp/holoviz_mcp/data.py | 20 ++++++++---- tests/docs_mcp/test_data.py | 48 +++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/holoviz_mcp/holoviz_mcp/data.py b/src/holoviz_mcp/holoviz_mcp/data.py index a079505..9f93104 100644 --- a/src/holoviz_mcp/holoviz_mcp/data.py +++ b/src/holoviz_mcp/holoviz_mcp/data.py @@ -1259,16 +1259,24 @@ def _clone_or_update_repo_sync(self, repo_name: str, repo_config: "GitRepository try: if repo_path.exists(): repo = git.Repo(repo_path) - expected_ref = repo_config.branch or repo_config.tag - if expected_ref: + if repo_config.tag: + # Tag checkouts leave the repo in detached HEAD; verify via tag→commit mapping. + matching_tag = next((t for t in repo.tags if t.name == repo_config.tag), None) + if matching_tag and matching_tag.commit == repo.head.commit: + # Already on the correct tag — tags are immutable, nothing to pull. + return repo_path + logger.info(f"Stale checkout for {repo_name}: expected tag '{repo_config.tag}'. Deleting and re-cloning...") + shutil.rmtree(repo_path) + # Fall through to clone below + elif repo_config.branch: try: - current_ref = repo.active_branch.name + current_branch = repo.active_branch.name except TypeError: - current_ref = None # Detached HEAD (e.g. tag/commit checkout) + current_branch = None # Detached HEAD - if current_ref != expected_ref: - logger.info(f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. " "Deleting and re-cloning...") + if current_branch != repo_config.branch: + logger.info(f"Stale checkout for {repo_name}: on '{current_branch}', expected '{repo_config.branch}'. Deleting and re-cloning...") shutil.rmtree(repo_path) # Fall through to clone below else: diff --git a/tests/docs_mcp/test_data.py b/tests/docs_mcp/test_data.py index 68ea6c1..905a89a 100644 --- a/tests/docs_mcp/test_data.py +++ b/tests/docs_mcp/test_data.py @@ -2058,8 +2058,46 @@ def test_tag_config_used_as_branch_kwarg_on_clone(self, tmp_path): _, kwargs = mock_clone_from.call_args assert kwargs.get("branch") == "v1.2.3" + def test_correct_tag_already_checked_out_returns_path(self, tmp_path): + """Existing repo already on the correct tag (detached HEAD): returns path, no re-clone, no pull.""" + from unittest.mock import MagicMock + from unittest.mock import patch + + indexer = self._make_indexer(tmp_path) + repo_config = GitRepository( + url=AnyHttpUrl("https://github.com/fake/repo.git"), + base_url=AnyHttpUrl("https://example.com/"), + tag="v1.2.3", + folders=["doc"], + ) + + repo_dir = tmp_path / "repos" / "repo" + repo_dir.mkdir(parents=True) + + # Simulate detached HEAD: active_branch raises TypeError (real GitPython behaviour) + mock_commit = MagicMock() + mock_tag = MagicMock() + mock_tag.name = "v1.2.3" + mock_tag.commit = mock_commit + + mock_repo = MagicMock() + mock_repo.tags = [mock_tag] + mock_repo.head.commit = mock_commit # same object → already on correct tag + + import git + + with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): + with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree") as mock_rmtree: + with patch.object(git.Repo, "clone_from") as mock_clone_from: + result = indexer._clone_or_update_repo_sync("repo", repo_config) + + assert result == repo_dir + mock_rmtree.assert_not_called() + mock_clone_from.assert_not_called() + mock_repo.remotes.origin.pull.assert_not_called() + def test_stale_tag_triggers_reclone(self, tmp_path): - """Existing repo on wrong tag: dir is deleted and re-cloned on the correct tag.""" + """Existing repo on wrong tag (detached HEAD): dir is deleted and re-cloned on the correct tag.""" from unittest.mock import MagicMock from unittest.mock import patch @@ -2076,8 +2114,14 @@ def test_stale_tag_triggers_reclone(self, tmp_path): repo_dir = tmp_path / "repos" / "repo" repo_dir.mkdir(parents=True) + # Simulate detached HEAD at a different tag — tag commit != HEAD commit + mock_tag = MagicMock() + mock_tag.name = "v1.2.3" + mock_tag.commit = MagicMock() # tag points somewhere + mock_repo = MagicMock() - mock_repo.active_branch.name = "v0.9.0" + mock_repo.tags = [mock_tag] + mock_repo.head.commit = MagicMock() # HEAD is elsewhere → stale with patch("holoviz_mcp.holoviz_mcp.data.git.Repo", return_value=mock_repo): with patch("holoviz_mcp.holoviz_mcp.data.shutil.rmtree") as mock_rmtree: