"""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. NOTE: These tests have known issues with daemon interference and require the bd daemon to be stopped before running. They may also have flaky behavior due to path caching issues. See bd-4aao for details. """ 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, ) # Initialize beads in main repo BEFORE creating worktree # Use --no-daemon to avoid interference from running daemon with cached paths init_result = subprocess.run( ["bd", "--no-daemon", "init", "--prefix", "main"], cwd=main_repo, capture_output=True, text=True, ) if init_result.returncode != 0: raise RuntimeError(f"bd init in main failed: {init_result.stderr}") # Verify main repo has .beads directory (database is always beads.db, prefix is for issue IDs) assert (main_repo / ".beads").exists(), f"Main repo should have .beads directory. Init output: {init_result.stdout} {init_result.stderr}" assert (main_repo / ".beads" / "beads.db").exists(), "Main repo should have database" # Create a worktree AFTER initializing beads in main subprocess.run( ["git", "worktree", "add", str(worktree), "-b", "feature"], cwd=main_repo, check=True, capture_output=True, ) # 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, ) # Re-sync after commit to avoid staleness check issues subprocess.run(["bd", "--no-daemon", "sync"], cwd=main_repo, capture_output=True) # Initialize beads in worktree (separate database, different prefix) # Use --no-daemon to avoid interference from running daemon init_result = subprocess.run( ["bd", "--no-daemon", "init", "--prefix", "feature"], cwd=worktree, capture_output=True, text=True, ) if init_result.returncode != 0: raise RuntimeError(f"bd init in worktree failed: {init_result.stderr}") # Verify worktree has its own .beads directory (database is always beads.db) assert (worktree / ".beads").exists(), f"Worktree should have .beads directory. Init output: {init_result.stdout} {init_result.stderr}" assert (worktree / ".beads" / "beads.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) # Re-sync after commit to avoid staleness check issues subprocess.run(["bd", "--no-daemon", "sync"], cwd=worktree, capture_output=True) yield main_repo, worktree, temp_dir finally: # Cleanup shutil.rmtree(temp_dir, ignore_errors=True) @pytest.mark.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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 (use --no-daemon to avoid daemon interference) result = subprocess.run( ["bd", "--no-daemon", "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", "--no-daemon", "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", "--no-daemon", "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", "--no-daemon", "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.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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", "--no-daemon", "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", "--no-daemon", "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", "--no-daemon", "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", "--no-daemon", "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.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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._connection_pool.clear() 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._connection_pool.clear() @pytest.mark.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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 (use --no-daemon to avoid daemon interference) result = subprocess.run( ["bd", "--no-daemon", "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", "--no-daemon", "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.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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 (use --no-daemon to avoid daemon interference) subprocess.run( ["bd", "--no-daemon", "create", "Feature issue", "-p", "1"], cwd=worktree, check=True, capture_output=True, ) # Export from worktree subprocess.run( ["bd", "--no-daemon", "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.skip(reason="Flaky due to daemon interference - requires daemon to be stopped") @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"