"""Integration test for separate beads databases per worktree. Tests the recommended workflow: each worktree has its own .beads database, and changes sync via git commits/pulls of the .beads/issues.jsonl file. """ import asyncio import json import os import shutil import subprocess import tempfile from pathlib import Path import pytest from fastmcp.client import Client from beads_mcp.server import mcp @pytest.fixture(scope="session") def bd_executable(): """Verify bd is available in PATH.""" bd_path = shutil.which("bd") if not bd_path: pytest.fail( "bd executable not found in PATH. " "Please install bd or add it to your PATH before running integration tests." ) return bd_path @pytest.fixture async def git_worktree_with_separate_dbs(bd_executable): """Create a git repo with a worktree, each with its own beads database. Returns: tuple: (main_repo_path, worktree_path, temp_dir) """ # Create temp directory temp_dir = tempfile.mkdtemp(prefix="beads_worktree_separate_") main_repo = Path(temp_dir) / "main" worktree = Path(temp_dir) / "worktree" try: # Initialize main git repo main_repo.mkdir() subprocess.run(["git", "init"], cwd=main_repo, check=True, capture_output=True) subprocess.run( ["git", "config", "user.email", "test@example.com"], cwd=main_repo, check=True, capture_output=True, ) subprocess.run( ["git", "config", "user.name", "Test User"], cwd=main_repo, check=True, capture_output=True, ) # Create initial commit readme = main_repo / "README.md" readme.write_text("# Test Repo\n") subprocess.run(["git", "add", "README.md"], cwd=main_repo, check=True, capture_output=True) subprocess.run( ["git", "commit", "-m", "Initial commit"], cwd=main_repo, check=True, capture_output=True, ) # Create a worktree BEFORE initializing beads subprocess.run( ["git", "worktree", "add", str(worktree), "-b", "feature"], cwd=main_repo, check=True, capture_output=True, ) # Initialize beads in main repo subprocess.run( ["bd", "init", "--prefix", "main"], cwd=main_repo, check=True, capture_output=True, ) # Verify main repo has .beads directory assert (main_repo / ".beads").exists(), "Main repo should have .beads directory" assert (main_repo / ".beads" / "main.db").exists(), "Main repo should have database" # Commit the .beads directory to git in main repo subprocess.run(["git", "add", ".beads"], cwd=main_repo, check=True, capture_output=True) subprocess.run( ["git", "commit", "-m", "Add beads to main"], cwd=main_repo, check=True, capture_output=True, ) # Initialize beads in worktree (separate database, different prefix) subprocess.run( ["bd", "init", "--prefix", "feature"], cwd=worktree, check=True, capture_output=True, ) # Verify worktree has its own .beads directory assert (worktree / ".beads").exists(), "Worktree should have .beads directory" assert (worktree / ".beads" / "feature.db").exists(), "Worktree should have database" # Commit the worktree's .beads (will replace/update main's .beads on feature branch) subprocess.run(["git", "add", ".beads"], cwd=worktree, check=True, capture_output=True) result = subprocess.run( ["git", "commit", "-m", "Add beads to feature branch"], cwd=worktree, capture_output=True, text=True, ) # Commit may fail if nothing changed (that's ok for our tests) if result.returncode != 0 and "nothing to commit" not in result.stdout: raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) yield main_repo, worktree, temp_dir finally: # Cleanup shutil.rmtree(temp_dir, ignore_errors=True) @pytest.mark.asyncio async def test_separate_databases_are_isolated(git_worktree_with_separate_dbs, bd_executable): """Test that each worktree has its own isolated database.""" main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Create issue in main repo result = subprocess.run( ["bd", "create", "Main repo issue", "-p", "1", "--json"], cwd=main_repo, capture_output=True, text=True, ) assert result.returncode == 0, f"Create in main failed: {result.stderr}" main_issue = json.loads(result.stdout) assert main_issue["id"].startswith("main-"), f"Expected main- prefix, got {main_issue['id']}" # Create issue in worktree result = subprocess.run( ["bd", "create", "Worktree issue", "-p", "1", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0, f"Create in worktree failed: {result.stderr}" worktree_issue = json.loads(result.stdout) assert worktree_issue["id"].startswith("feature-"), f"Expected feature- prefix, got {worktree_issue['id']}" # List issues in main repo result = subprocess.run( ["bd", "list", "--json"], cwd=main_repo, capture_output=True, text=True, ) assert result.returncode == 0 main_issues = json.loads(result.stdout) main_ids = [issue["id"] for issue in main_issues] # List issues in worktree result = subprocess.run( ["bd", "list", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0 worktree_issues = json.loads(result.stdout) worktree_ids = [issue["id"] for issue in worktree_issues] # Verify isolation - main repo shouldn't see worktree issues and vice versa assert "main-1" in main_ids, "Main repo should see its own issue" assert "feature-1" not in main_ids, "Main repo should NOT see worktree issue" assert "feature-1" in worktree_ids, "Worktree should see its own issue" assert "main-1" not in worktree_ids, "Worktree should NOT see main repo issue (yet)" @pytest.mark.asyncio async def test_changes_sync_via_git(git_worktree_with_separate_dbs, bd_executable): """Test that changes sync between worktrees via git commits and merges.""" main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Create and commit issue in main repo result = subprocess.run( ["bd", "create", "Shared issue", "-p", "1", "--json"], cwd=main_repo, capture_output=True, text=True, ) assert result.returncode == 0 main_issue = json.loads(result.stdout) # Export to JSONL (should happen automatically, but force it) subprocess.run( ["bd", "export", "-o", ".beads/issues.jsonl"], cwd=main_repo, check=True, capture_output=True, ) # Commit the JSONL file subprocess.run(["git", "add", ".beads/issues.jsonl"], cwd=main_repo, check=True, capture_output=True) subprocess.run( ["git", "commit", "-m", "Add shared issue"], cwd=main_repo, check=True, capture_output=True, ) # Switch worktree to main branch temporarily to pull changes subprocess.run( ["git", "fetch", "origin", "master:master"], cwd=worktree, capture_output=True, # May fail if no remote, that's ok ) subprocess.run( ["git", "merge", "master"], cwd=worktree, capture_output=True, # May have conflicts, handle below ) # Import the changes into worktree database result = subprocess.run( ["bd", "import", "-i", ".beads/issues.jsonl"], cwd=worktree, capture_output=True, text=True, ) # If import succeeded, verify the issue is now visible if result.returncode == 0: result = subprocess.run( ["bd", "list", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0 worktree_issues = json.loads(result.stdout) worktree_ids = [issue["id"] for issue in worktree_issues] # After import, worktree should see the main repo issue # (with potentially remapped ID due to prefix difference) assert len(worktree_issues) >= 1, "Worktree should have at least one issue after import" @pytest.mark.asyncio async def test_mcp_works_with_separate_databases(git_worktree_with_separate_dbs, monkeypatch): """Test that MCP server works independently in each worktree with daemon-less mode.""" from beads_mcp import tools from beads_mcp.bd_client import BdCliClient main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Configure MCP for daemon-less mode in worktree tools._client = None monkeypatch.setenv("BEADS_USE_DAEMON", "0") monkeypatch.setenv("BEADS_WORKING_DIR", str(worktree)) # Reset context if "BEADS_CONTEXT_SET" in os.environ: monkeypatch.delenv("BEADS_CONTEXT_SET") # Create MCP client async with Client(mcp) as client: # Set context to worktree await client.call_tool("set_context", {"workspace_root": str(worktree)}) # Create issue via MCP result = await client.call_tool( "create", { "title": "MCP issue in worktree", "description": "Created via MCP in daemon-less mode", "priority": 1, }, ) assert result.is_error is False, f"Create failed: {result.content}" # Parse result content = result.content[0].text issue_data = json.loads(content) assert issue_data["id"].startswith("feature-"), "Issue should have feature- prefix" # List via MCP list_result = await client.call_tool("list", {}) assert list_result.is_error is False # Verify isolation - should only see worktree issues list_content = list_result.content[0].text assert "feature-" in list_content, "Should see worktree issues" assert "main-" not in list_content, "Should NOT see main repo issues" # Cleanup tools._client = None @pytest.mark.asyncio async def test_worktree_database_discovery(git_worktree_with_separate_dbs, bd_executable): """Test that bd correctly discovers the database in each worktree.""" main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Test main repo can find its database result = subprocess.run( ["bd", "list", "--json"], cwd=main_repo, capture_output=True, text=True, ) assert result.returncode == 0, f"Main repo should find its database: {result.stderr}" # Test worktree can find its database result = subprocess.run( ["bd", "list", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0, f"Worktree should find its database: {result.stderr}" # Both should work - that's what matters for this test # (The prefix doesn't matter as much as the fact that both can operate) @pytest.mark.asyncio async def test_jsonl_export_works_in_worktrees(git_worktree_with_separate_dbs, bd_executable): """Test that JSONL export works correctly in worktrees.""" main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Create issue in worktree subprocess.run( ["bd", "create", "Feature issue", "-p", "1"], cwd=worktree, check=True, capture_output=True, ) # Export from worktree subprocess.run( ["bd", "export", "-o", ".beads/issues.jsonl"], cwd=worktree, check=True, capture_output=True, ) # Verify JSONL file exists and contains the issue worktree_jsonl = (worktree / ".beads" / "issues.jsonl").read_text() assert "Feature issue" in worktree_jsonl, "JSONL should contain the created issue" assert len(worktree_jsonl) > 0, "JSONL should not be empty" @pytest.mark.asyncio async def test_no_daemon_flag_works_in_worktree(git_worktree_with_separate_dbs, bd_executable): """Test that --no-daemon flag works correctly in worktrees.""" main_repo, worktree, temp_dir = git_worktree_with_separate_dbs # Create issue with --no-daemon flag result = subprocess.run( ["bd", "--no-daemon", "create", "No daemon issue", "-p", "1", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0, f"--no-daemon create should work: {result.stderr}" issue_data = json.loads(result.stdout) assert issue_data["id"].startswith("feature-"), "Should use worktree database" # List with --no-daemon result = subprocess.run( ["bd", "--no-daemon", "list", "--json"], cwd=worktree, capture_output=True, text=True, ) assert result.returncode == 0, f"--no-daemon list should work: {result.stderr}" issues = json.loads(result.stdout) assert len(issues) > 0, "Should see created issue" assert issues[0]["id"].startswith("feature-"), "Should list worktree issues"