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
20 changes: 18 additions & 2 deletions libs/beacon/src/beacon/cli/warehouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,15 @@ def warehouse_contribute(*, message: str, push: bool, paths: tuple[str, ...]) ->
sys.exit(1)

if result.status == "no_changes":
console.print("[yellow]No uncommitted changes to contribute.[/yellow]")
if result.dirty_outside_scope_count > 0:
console.print(
f"[yellow]Note: {result.dirty_outside_scope_count} dirty file(s) in warehouse outside this project's beacon.yaml scope.[/yellow]"
)
console.print(
"[yellow]Run 'abc warehouse status --all' to see them, or contribute from a project that tracks them.[/yellow]"
)
else:
console.print("[yellow]No uncommitted changes to contribute.[/yellow]")
return

if result.status == "committed":
Expand Down Expand Up @@ -478,7 +486,15 @@ def warehouse_status_cmd(*, path: str | None, all_paths: bool) -> None:
return

if not result.modifications:
console.print("[green]✓ Working tree is clean.[/green]")
if result.dirty_outside_scope_count > 0:
console.print(
f"[yellow]Note: {result.dirty_outside_scope_count} dirty file(s) in warehouse outside this project's beacon.yaml scope.[/yellow]"
)
console.print(
"[yellow]Run 'abc warehouse status --all' to see them, or contribute from a project that tracks them.[/yellow]"
)
else:
console.print("[green]✓ Working tree is clean.[/green]")
else:
console.print("[bold]Modified files:[/bold]")
for entry in result.modifications:
Expand Down
30 changes: 28 additions & 2 deletions libs/beacon/src/beacon/domains/warehouse/contribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ContributeResult:
status: str # "committed", "no_changes", "push_failed"
committed_sha: str | None = None
message: str | None = None
dirty_outside_scope_count: int = 0


def _run_git(
Expand All @@ -35,6 +36,21 @@ def _run_git(
)


def _count_dirty_outside_scope(warehouse_path: Path, tracked: list[str]) -> int:
"""Return number of dirty files outside the tracked set."""
full = _run_git(warehouse_path, ["status", "--porcelain"])
if full.returncode != 0:
return 0
total_lines = len([ln for ln in full.stdout.splitlines() if ln.strip()])
filtered = _run_git(warehouse_path, ["status", "--porcelain", "--", *tracked])
filtered_lines = (
len([ln for ln in filtered.stdout.splitlines() if ln.strip()])
if filtered.returncode == 0
else 0
)
return max(0, total_lines - filtered_lines)


def contribute(
project_root: Path,
*,
Expand Down Expand Up @@ -88,14 +104,24 @@ def contribute(
commit_paths = tracked_paths

if not commit_paths:
return ContributeResult(status="no_changes")
count = (
_count_dirty_outside_scope(warehouse_path, tracked_paths)
if paths is None
else 0
)
return ContributeResult(status="no_changes", dirty_outside_scope_count=count)

# Check git status for the paths we intend to commit
status_result = _run_git(
warehouse_path, ["status", "--porcelain", "--", *commit_paths]
)
if not status_result.stdout.strip():
return ContributeResult(status="no_changes")
count = (
_count_dirty_outside_scope(warehouse_path, tracked_paths)
if paths is None
else 0
)
return ContributeResult(status="no_changes", dirty_outside_scope_count=count)

# Stage the paths we intend to commit
_run_git(warehouse_path, ["add", "--", *commit_paths])
Expand Down
15 changes: 15 additions & 0 deletions libs/beacon/src/beacon/domains/warehouse/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class StatusResult:
behind: int | None = None
has_upstream: bool = True
diff: str | None = None
dirty_outside_scope_count: int = 0


def _run_git(
Expand Down Expand Up @@ -135,4 +136,18 @@ def status(
file_path = line[3:]
result.modifications.append(StatusEntry(path=file_path, status=code))

# Compute out-of-scope dirty count when filtering is active
if path is None and not all_paths:
full_status = _run_git(warehouse_path, ["status", "--porcelain"])
if full_status.returncode == 0:
total_lines = len(
[ln for ln in full_status.stdout.splitlines() if ln.strip()]
)
filtered_lines = len(
[ln for ln in status_result.stdout.splitlines() if ln.strip()]
)
result.dirty_outside_scope_count = max(0, total_lines - filtered_lines)
else:
result.dirty_outside_scope_count = 0

return result
31 changes: 31 additions & 0 deletions libs/beacon/tests/unit/warehouse/test_contribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,34 @@ def test_contribute_paths_sequential_groups_leave_others_dirty(
)
assert "commit b" in log.stdout
assert "commit a" in log.stdout


class TestDirtyOutsideScope:
"""Tests for PER-159: out-of-scope dirty file count in contribute."""

def test_no_changes_with_outside_scope_dirty_returns_count(self, contrib_project):
"""Dirty file outside beacon.yaml -> no_changes with count >= 1."""
project, wh = contrib_project
(wh / "untracked.md").write_text("# Untracked\nmodified\n")

result = contribute(project, message="x", push=False)
assert result.status == "no_changes"
assert result.dirty_outside_scope_count >= 1

def test_no_changes_with_clean_tree_returns_zero_count(self, contrib_project):
"""Clean warehouse -> dirty_outside_scope_count == 0."""
project, wh = contrib_project
result = contribute(project, message="x", push=False)
assert result.status == "no_changes"
assert result.dirty_outside_scope_count == 0

def test_commit_path_does_not_compute_outside_scope(self, contrib_project_multi):
"""Committed result has dirty_outside_scope_count == 0."""
project, wh = contrib_project_multi

# Dirty a tracked file
(wh / "contexts" / "a.md").write_text("# a modified\n")

result = contribute(project, message="commit a", push=False)
assert result.status == "committed"
assert result.dirty_outside_scope_count == 0
40 changes: 40 additions & 0 deletions libs/beacon/tests/unit/warehouse/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,43 @@ def test_untracked_path_raises(self, status_project):
with pytest.raises(ValueError) as exc_info:
status(project, path="untracked.md")
assert "not tracked" in str(exc_info.value).lower()


class TestDirtyOutsideScope:
"""Tests for PER-159: out-of-scope dirty file count."""

def test_dirty_outside_scope_only_yields_count(self, status_project):
"""Modify a git-tracked file not in beacon.yaml -> count > 0."""
project, wh = status_project
(wh / "untracked.md").write_text("# Untracked\nmodified\n")

result = status(project)
assert result.modifications == []
assert result.dirty_outside_scope_count >= 1

def test_clean_warehouse_yields_zero_count(self, status_project):
"""Clean warehouse -> dirty_outside_scope_count == 0."""
project, wh = status_project
result = status(project)
assert result.dirty_outside_scope_count == 0

def test_dirty_tracked_present_yields_zero_count(self, status_project):
"""In-scope dirty files present -> out-of-scope count == 0."""
project, wh = status_project
(wh / "contexts" / "test.md").write_text("# Test\nmodified\n")

result = status(project)
assert len(result.modifications) >= 1
assert result.dirty_outside_scope_count == 0

def test_all_paths_does_not_compute_outside_scope(self, status_project):
"""--all mode skips out-of-scope computation."""
project, wh = status_project
(wh / "contexts" / "test.md").write_text("# Test\nmodified\n")
(wh / "untracked.md").write_text("# Untracked\nmodified\n")

result = status(project, all_paths=True)
paths = [m.path for m in result.modifications]
assert "contexts/test.md" in paths
assert "untracked.md" in paths
assert result.dirty_outside_scope_count == 0
Loading