Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions bough/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ class BoughAnalyzer:

def __init__(
self,
repo_root: Path,
workspace_root: Path,
config: "BoughConfig",
packages: dict[str, Package] | None = None,
) -> None:
self.repo_root = repo_root
self.workspace_root = workspace_root
logger.debug(f"Initializing analyzer for workspace: {workspace_root}")
self.config = config
Expand All @@ -54,10 +56,12 @@ def __init__(
logger.debug(f"Discovered {len(self.packages)} packages")

@classmethod
def from_workspace(cls, workspace_root: Path, config_path: Path) -> "BoughAnalyzer":
def from_workspace(
cls, repo_root: Path, workspace_root: Path, config_path: Path
) -> "BoughAnalyzer":
"""Create analyzer by discovering packages from workspace."""
config = load_config(config_path)
return cls(workspace_root, config)
return cls(repo_root, workspace_root, config)

def _discover_packages(self) -> None:
root_pyproject = self.workspace_root / "pyproject.toml"
Expand Down Expand Up @@ -159,8 +163,9 @@ def _find_direct_packages(
changed_files: set[str],
) -> set[str]:
directly_affected = set()
strip_prefix = self.workspace_root.relative_to(self.repo_root)
for file_path in changed_files:
file_path_obj = Path(file_path)
file_path_obj = Path(file_path).relative_to(strip_prefix)

if self._matches_patterns(file_path, self.config.ignore):
logger.debug(f"Ignoring file {file_path} (matches ignore patterns)")
Expand Down Expand Up @@ -210,7 +215,7 @@ def find_affected(
"""Return the affected packages and files."""
logger.debug(f"Analyzing changes from {base_commit} to HEAD")

files = find_changed_files(self.workspace_root, base_commit)
files = find_changed_files(self.repo_root, base_commit)
all_affected = self._find_transitive_packages(self._find_direct_packages(files))

if selection != "buildable":
Expand Down
8 changes: 7 additions & 1 deletion bough/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ def main() -> None:
default=Path.cwd(),
help="Path to workspace root (default: current directory)",
)
default_parser.add_argument(
"--repo",
type=Path,
default=Path.cwd(),
help="Path to git repository root (default: current directory)",
)
default_parser.add_argument(
"--verbose",
"-v",
Expand Down Expand Up @@ -74,7 +80,7 @@ def main() -> None:
config_path = args.config or args.workspace / ".bough.yaml"

try:
analyzer = BoughAnalyzer.from_workspace(args.workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(args.repo, args.workspace, config_path)

if args.command == "graph":
output = fmt.dependency_graph(analyzer)
Expand Down
9 changes: 8 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ def test_smoke_cli_graph():


def test_smoke_cli_analyze():
sys.argv = ["bough", "analyze", "--workspace", "tests/fixtures/sample-workspace"]
sys.argv = [
"bough",
"analyze",
"--workspace",
"tests/fixtures/sample-workspace",
"--repo",
"tests/fixtures/sample-workspace",
]
with pytest.raises(SystemExit, match="0"):
cli.main()
50 changes: 39 additions & 11 deletions tests/test_complex_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def create_workspace_structure(base_path: Path, structure: dict):
"project": {"name": "test-workspace", "version": "0.1.0"},
}

base_path.mkdir(parents=True, exist_ok=True)
with open(base_path / "pyproject.toml", "wb") as f:
tomli_w.dump(root_config, f)

Expand Down Expand Up @@ -105,7 +106,7 @@ def test_deep_dependency_chain_core_change(mock_repo_class, tmp_path):
""")

# Test the public interface
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

# Core change should transitively affect all buildable packages
Expand Down Expand Up @@ -133,7 +134,7 @@ def test_diamond_dependency_pattern(mock_repo_class, tmp_path):
- "*.md"
""")

analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

# Base change should affect top through both left and right paths
Expand Down Expand Up @@ -171,14 +172,14 @@ def test_layered_architecture_isolation(mock_repo_class, tmp_path):

# Test 1: Change in models affects all apps
mock_repo_class.return_value = mock_git_changes(["data/models/user.py"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == {"rest-api", "graphql-api", "worker"}

# Test 2: Change in middleware only affects APIs that use it
mock_repo_class.return_value = mock_git_changes(["api/middleware/auth.py"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == {"rest-api", "graphql-api"}
Expand Down Expand Up @@ -230,7 +231,7 @@ def test_microservices_shared_library_impact(mock_repo_class, tmp_path):

# Test 1: Change in proto affects everything
mock_repo_class.return_value = mock_git_changes(["shared/proto/user.proto"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

expected = {
Expand All @@ -245,7 +246,7 @@ def test_microservices_shared_library_impact(mock_repo_class, tmp_path):

# Test 2: Change in events only affects services that use events
mock_repo_class.return_value = mock_git_changes(["shared/events/order_created.py"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

expected = {"user-service", "order-service", "notification-service"}
Expand Down Expand Up @@ -276,7 +277,7 @@ def test_isolated_packages_no_unnecessary_rebuilds(mock_repo_class, tmp_path):
mock_repo_class.return_value = mock_git_changes(
["tools/standalone-b/standalone_b.py"],
)
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == {"standalone-b"}
Expand All @@ -285,7 +286,7 @@ def test_isolated_packages_no_unnecessary_rebuilds(mock_repo_class, tmp_path):
mock_repo_class.return_value = mock_git_changes(
["tools/standalone-a/standalone_a.py"],
)
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == {"standalone-a", "main"}
Expand Down Expand Up @@ -313,7 +314,7 @@ def test_root_file_affects_all_buildable_packages(mock_repo_class, tmp_path):

# Root pyproject.toml change should affect all buildable packages
mock_repo_class.return_value = mock_git_changes(["pyproject.toml"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == {"web", "api"} # Only buildable packages
Expand Down Expand Up @@ -343,7 +344,7 @@ def test_ignored_files_trigger_no_rebuilds(mock_repo_class, tmp_path):
mock_repo_class.return_value = mock_git_changes(
["README.md", "docs/api.md", "CHANGELOG.txt"],
)
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

assert affected == set()
Expand Down Expand Up @@ -376,8 +377,35 @@ def test_complex_buildable_patterns(mock_repo_class, tmp_path):

# Change common library to affect everything
mock_repo_class.return_value = mock_git_changes(["libs/common/common.py"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, config_path)
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path, config_path)
affected, _ = analyzer.find_affected()

# Should only include packages matching buildable patterns
assert affected == {"user-service", "api-gateway", "web"}


@patch("git.Repo")
def test_divergent_git_vs_workspace_roots(mock_repo_class, tmp_path):
"""Test that root file changes affect all buildable packages."""
structure = {
"libs/utils": {"dependencies": []},
"apps/web": {"dependencies": ["utils"]},
"apps/api": {"dependencies": ["utils"]},
"tools/cli": {"dependencies": []},
}

create_workspace_structure(tmp_path / "src", structure)

config_path = tmp_path / ".bough.yml"
config_path.write_text("""
buildable:
- "apps/*"
ignore:
- "*.md"
""")

mock_repo_class.return_value = mock_git_changes(["src/apps/web/foo"])
analyzer = BoughAnalyzer.from_workspace(tmp_path, tmp_path / "src", config_path)
affected, _ = analyzer.find_affected()

assert affected == {"web"}
8 changes: 6 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def test_analyzer_loads_config(sample_workspace, tmp_path):
- "docs/**"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path
)

# Should have loaded config from .bough.yml in the workspace
assert analyzer.config is not None
Expand All @@ -94,7 +96,9 @@ def test_analyzer_custom_config_path(sample_workspace, tmp_path):
- "*.tmp"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path=custom_config)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path=custom_config
)

assert analyzer.config.buildable == ["services/*"]
assert analyzer.config.ignore == ["*.log", "*.tmp"]
Expand Down
16 changes: 12 additions & 4 deletions tests/test_config_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ def test_custom_buildable_patterns_filter_correctly(sample_workspace, tmp_path):
- "*.md"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path
)

# Modify a file that affects everything

Expand Down Expand Up @@ -54,7 +56,9 @@ def test_multiple_buildable_patterns(sample_workspace, tmp_path):
- "*.md"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path
)

repo = git.Repo.init(sample_workspace)
with repo.config_writer() as config:
Expand Down Expand Up @@ -91,7 +95,9 @@ def test_custom_ignore_patterns_work(sample_workspace, tmp_path):
- "docs/**"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path
)

repo = git.Repo.init(sample_workspace)
with repo.config_writer() as config:
Expand Down Expand Up @@ -125,7 +131,9 @@ def test_nested_ignore_patterns(sample_workspace, tmp_path):
- "docs/**"
""")

analyzer = BoughAnalyzer.from_workspace(sample_workspace, config_path)
analyzer = BoughAnalyzer.from_workspace(
sample_workspace, sample_workspace, config_path
)

repo = git.Repo.init(sample_workspace)
with repo.config_writer() as config:
Expand Down
36 changes: 16 additions & 20 deletions tests/test_formatters.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
from hypothesis import given, strategies as st
from unittest.mock import Mock
from pathlib import Path
import json
from pathlib import Path
from unittest.mock import Mock

from hypothesis import given
from hypothesis import strategies as st

import bough.formatters as sut

package_name = st.text(
min_size=1,
max_size=20,
alphabet=st.characters(blacklist_characters='\n\r')
min_size=1, max_size=20, alphabet=st.characters(blacklist_characters="\n\r")
)

packages = st.sets(package_name, min_size=1)
files = st.sets(package_name, min_size=1)


@given(packages)
def test_github_matrix_always_valid_json(package_names):
"""GitHub matrix output must always be valid JSON."""
analyzer = Mock()
analyzer.packages = {
name: Mock(directory=Path(f"/root/{name}"))
for name in package_names
name: Mock(directory=Path(f"/root/{name}")) for name in package_names
}
analyzer.workspace_root = Path("/root")

Expand All @@ -35,8 +36,7 @@ def test_quiet_output_has_correct_line_count(package_names):
"""Quiet mode outputs exactly one line per package."""
analyzer = Mock()
analyzer.packages = {
name: Mock(directory=Path(f"/root/{name}"))
for name in package_names
name: Mock(directory=Path(f"/root/{name}")) for name in package_names
}

result = sut.quiet(analyzer, package_names)
Expand All @@ -49,8 +49,7 @@ def test_human_readable_contains_all_packages(packages, files):
"""All package names must appear in human readable output."""
analyzer = Mock()
analyzer.packages = {
name: Mock(directory=Path(f"/root/{name}"))
for name in packages
name: Mock(directory=Path(f"/root/{name}")) for name in packages
}
analyzer.workspace_root = Path("/root")

Expand All @@ -60,7 +59,6 @@ def test_human_readable_contains_all_packages(packages, files):
assert pkg in result



@st.composite
def package_graph(draw):
"""Generate a set of packages with dependencies referencing each other."""
Expand All @@ -72,22 +70,20 @@ def package_graph(draw):
for name in names:
deps = draw(st.sets(st.sampled_from(names), max_size=3)) - {name}
is_buildable = draw(st.booleans())
packages.append({
"name": name,
"dependencies": deps,
"is_buildable": is_buildable
})
packages.append(
{"name": name, "dependencies": deps, "is_buildable": is_buildable}
)

return packages


@given(package_graph())
def test_dependency_graph_contains_all_packages(packages):
"""All package names must appear in dependency graph output."""
analyzer = Mock()
analyzer.packages = {
pkg["name"]: Mock(
directory=Path(f"/root/{pkg['name']}"),
dependencies=pkg["dependencies"]
directory=Path(f"/root/{pkg['name']}"), dependencies=pkg["dependencies"]
)
for pkg in packages
}
Expand Down
2 changes: 2 additions & 0 deletions tests/test_git.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import tempfile
from pathlib import Path

import git
import pytest

import bough.git as sut


Expand Down
Loading