From 67d3d4b8d653ace3c934e9c2ff5525127f2ac195 Mon Sep 17 00:00:00 2001 From: Song Yikun Date: Mon, 18 May 2026 17:34:02 +0800 Subject: [PATCH] fix(warehouse): surface out-of-scope dirty file count in status/contribute Both `abc warehouse status` (no-arg) and `abc warehouse contribute` filter git status through the project's beacon.yaml-tracked set, so warehouse files outside that scope were invisible. Users saw "clean" / "no changes" while `git status` inside the warehouse showed dirty files, and concluded the CLI was broken. Add a `dirty_outside_scope_count` to `StatusResult` and `ContributeResult` and surface it in both CLI handlers when the in-scope set is clean but the warehouse has out-of-scope dirty files. Existing in-scope behavior unchanged. Closes PER-159. --- libs/beacon/src/beacon/cli/warehouse.py | 20 +++++++++- .../beacon/domains/warehouse/contribute.py | 30 +++++++++++++- .../src/beacon/domains/warehouse/status.py | 15 +++++++ .../tests/unit/warehouse/test_contribute.py | 31 ++++++++++++++ .../tests/unit/warehouse/test_status.py | 40 +++++++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/libs/beacon/src/beacon/cli/warehouse.py b/libs/beacon/src/beacon/cli/warehouse.py index d867356f..3c8c5428 100644 --- a/libs/beacon/src/beacon/cli/warehouse.py +++ b/libs/beacon/src/beacon/cli/warehouse.py @@ -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": @@ -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: diff --git a/libs/beacon/src/beacon/domains/warehouse/contribute.py b/libs/beacon/src/beacon/domains/warehouse/contribute.py index eae87632..8f459870 100644 --- a/libs/beacon/src/beacon/domains/warehouse/contribute.py +++ b/libs/beacon/src/beacon/domains/warehouse/contribute.py @@ -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( @@ -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, *, @@ -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]) diff --git a/libs/beacon/src/beacon/domains/warehouse/status.py b/libs/beacon/src/beacon/domains/warehouse/status.py index 62a1c145..9f72b305 100644 --- a/libs/beacon/src/beacon/domains/warehouse/status.py +++ b/libs/beacon/src/beacon/domains/warehouse/status.py @@ -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( @@ -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 diff --git a/libs/beacon/tests/unit/warehouse/test_contribute.py b/libs/beacon/tests/unit/warehouse/test_contribute.py index 519e7174..5a82d245 100644 --- a/libs/beacon/tests/unit/warehouse/test_contribute.py +++ b/libs/beacon/tests/unit/warehouse/test_contribute.py @@ -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 diff --git a/libs/beacon/tests/unit/warehouse/test_status.py b/libs/beacon/tests/unit/warehouse/test_status.py index 187c9683..b4baccd3 100644 --- a/libs/beacon/tests/unit/warehouse/test_status.py +++ b/libs/beacon/tests/unit/warehouse/test_status.py @@ -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