diff --git a/src/ouroboros/bigbang/brownfield.py b/src/ouroboros/bigbang/brownfield.py index 7849dd9b4..dff35e51b 100644 --- a/src/ouroboros/bigbang/brownfield.py +++ b/src/ouroboros/bigbang/brownfield.py @@ -4,7 +4,7 @@ via :class:`~ouroboros.persistence.brownfield.BrownfieldStore`. Business-level operations: -- Home directory scanning for git repos with GitHub origin +- Home directory scanning for git repos with an ``origin`` remote - README/CLAUDE.md parsing for one-line description generation (Frugal model) - Async CRUD delegated to BrownfieldStore @@ -76,14 +76,14 @@ # ── Home directory scanning ──────────────────────────────────────── -def _has_github_origin(repo_path: Path) -> bool: - """Check whether a git repo has a remote origin containing github.com. +def _has_origin_remote(repo_path: Path) -> bool: + """Check whether a git repo has a configured ``origin`` remote. Args: repo_path: Path to the repository root (parent of ``.git``). Returns: - True if any origin URL contains ``github.com``. + True if ``git remote get-url origin`` returns a non-empty URL. """ try: result = subprocess.run( @@ -92,20 +92,25 @@ def _has_github_origin(repo_path: Path) -> bool: text=True, timeout=5, ) - return "github.com" in result.stdout + return result.returncode == 0 and bool(result.stdout.strip()) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return False +def _has_github_origin(repo_path: Path) -> bool: + """Backward-compatible wrapper for callers using the old helper name.""" + return _has_origin_remote(repo_path) + + def scan_home_for_repos( root: Path | None = None, ) -> list[dict[str, str]]: - """Walk the home directory to find git repositories with GitHub origins. + """Walk the home directory to find git repositories with an ``origin`` remote. Scanning rules: - Prune subdirectories once ``.git`` is found (no nested repos) - Skip hardcoded noise directories (node_modules, .venv, etc.) - - Only include repos whose origin remote contains ``github.com`` + - Only include repos with a configured ``origin`` remote Args: root: Directory to start scanning. Defaults to ``~/``. @@ -124,7 +129,7 @@ def scan_home_for_repos( # Check for .git directory if ".git" in dirnames: - if _has_github_origin(current): + if _has_origin_remote(current): resolved = current.resolve() repos.append({"path": str(resolved), "name": resolved.name}) log.debug("brownfield.scan.found", path=str(current)) @@ -240,7 +245,7 @@ async def scan_and_register( This is the main entry point for ``ooo setup`` brownfield scanning. - 1. Walk ``~/`` to find git repos with GitHub origins. + 1. Walk ``~/`` to find git repos with an ``origin`` remote. 2. Bulk-insert all found repos with ``is_default=False`` and ``desc=""``. 3. Set the first repo as default if no default exists. @@ -335,7 +340,7 @@ async def register_repo( resolved_name = name or repo_path.name # Resolve only if the path exists on disk (avoids macOS /System/Volumes # prefix for non-existent paths in tests and cross-machine registrations). - canonical_path = str(repo_path.resolve()) if repo_path.exists() else str(repo_path) + canonical_path = str(repo_path.resolve()) if repo_path.exists() else path # Auto-generate description if not provided and LLM adapter is available if desc is None and llm_adapter is not None: diff --git a/src/ouroboros/cli/commands/setup.py b/src/ouroboros/cli/commands/setup.py index a5771fa63..898e4777e 100644 --- a/src/ouroboros/cli/commands/setup.py +++ b/src/ouroboros/cli/commands/setup.py @@ -1330,7 +1330,7 @@ def setup( def scan() -> None: """Re-scan home directory and register new repos. - Scans ~/ for git repos with GitHub origins and updates the + Scans ~/ for git repos with configured origin remotes and updates the brownfield registry. Existing repos are preserved (upsert). """ console.print("\n[bold cyan]Brownfield Scan[/]\n") diff --git a/src/ouroboros/mcp/tools/brownfield_handler.py b/src/ouroboros/mcp/tools/brownfield_handler.py index 6f0dc2f78..be87d09e0 100644 --- a/src/ouroboros/mcp/tools/brownfield_handler.py +++ b/src/ouroboros/mcp/tools/brownfield_handler.py @@ -3,9 +3,8 @@ Provides an ``ouroboros_brownfield`` MCP tool for managing brownfield repository registrations in the SQLite database. Supports four actions: -- **scan** — Scan home directory for git repos with GitHub origins and - register them in the DB. Optionally generates one-line descriptions - via a Frugal-tier LLM. +- **scan** — Scan an explicit root (or a caller-provided default root) for git + repos/worktrees with an ``origin`` remote and register them in the DB. - **register** — Manually register a single repository by path. - **query** — List all registered repos or get the current default. - **set_default** — Set a registered repo as the default brownfield context. @@ -73,7 +72,7 @@ class BrownfieldHandler: Manages brownfield repository registrations with action-based dispatch: - - ``scan`` — Walk ``~/`` for GitHub repos and register them. + - ``scan`` — Walk a caller-supplied root for repos with an ``origin`` remote and register them. - ``register`` — Manually register one repo. - ``query`` — List repos or fetch the default. - ``set_default`` — Set a repo as the default brownfield context. @@ -91,7 +90,7 @@ def definition(self) -> MCPToolDefinition: name=_TOOL_NAME, description=( "Manage brownfield repository registrations. " - "Scan home directory for repos, register/query repos, " + "Scan a requested root for repos, register/query repos, " "or set the default brownfield context for PM interviews." ), parameters=( @@ -99,7 +98,7 @@ def definition(self) -> MCPToolDefinition: name="action", type=ToolInputType.STRING, description=( - "Action to perform: 'scan' to discover repos from ~/," + "Action to perform: 'scan' to discover repos from a caller-provided root," " 'register' to add a single repo," " 'query' to list all repos or get default," " 'set_default' to toggle a repo's default flag" @@ -158,7 +157,7 @@ def definition(self) -> MCPToolDefinition: MCPToolParameter( name="scan_root", type=ToolInputType.STRING, - description=("Root directory for 'scan' action. Defaults to ~/."), + description=("Root directory for 'scan' action. Caller chooses the scan root."), required=False, ), MCPToolParameter( @@ -259,10 +258,10 @@ async def _handle_scan( self, arguments: dict[str, Any], ) -> Result[MCPToolResult, MCPServerError]: - """Scan home directory for git repos and register them. + """Scan a caller-provided root for git repos and register them. Delegates to ``bigbang.brownfield.scan_and_register`` which handles - directory walking, GitHub origin filtering, LLM description generation, + directory walking, origin-remote filtering, LLM description generation, and DB upsert. """ scan_root_str = arguments.get("scan_root") @@ -271,7 +270,7 @@ async def _handle_scan( store = await self._get_store() # scan_and_register handles the full workflow: - # walk dirs → filter GitHub origins → generate descs → upsert + # walk dirs → filter origin remotes → generate descs → upsert repos = await scan_and_register( store=store, llm_adapter=None, # No LLM in MCP context for now diff --git a/tests/unit/bigbang/test_brownfield.py b/tests/unit/bigbang/test_brownfield.py index 71808f0e5..de404d2fe 100644 --- a/tests/unit/bigbang/test_brownfield.py +++ b/tests/unit/bigbang/test_brownfield.py @@ -11,6 +11,7 @@ from ouroboros.bigbang.brownfield import ( _SKIP_DIRS, BrownfieldEntry, + _has_origin_remote, _has_github_origin, _read_readme_content, generate_desc, @@ -68,8 +69,8 @@ def test_from_dict_empty_name_raises(self) -> None: # ── _has_github_origin ───────────────────────────────────────────── -class TestHasGithubOrigin: - """Tests for _has_github_origin helper.""" +class TestHasOriginRemote: + """Tests for origin-remote helpers.""" def test_returns_true_for_github_origin(self, tmp_path: Path) -> None: # Create a real git repo with a GitHub origin @@ -86,9 +87,10 @@ def test_returns_true_for_github_origin(self, tmp_path: Path) -> None: ], capture_output=True, ) + assert _has_origin_remote(tmp_path) is True assert _has_github_origin(tmp_path) is True - def test_returns_false_for_non_github_origin(self, tmp_path: Path) -> None: + def test_returns_true_for_non_github_origin(self, tmp_path: Path) -> None: subprocess.run(["git", "init", str(tmp_path)], capture_output=True) subprocess.run( [ @@ -102,13 +104,16 @@ def test_returns_false_for_non_github_origin(self, tmp_path: Path) -> None: ], capture_output=True, ) - assert _has_github_origin(tmp_path) is False + assert _has_origin_remote(tmp_path) is True + assert _has_github_origin(tmp_path) is True def test_returns_false_for_no_origin(self, tmp_path: Path) -> None: subprocess.run(["git", "init", str(tmp_path)], capture_output=True) + assert _has_origin_remote(tmp_path) is False assert _has_github_origin(tmp_path) is False def test_returns_false_for_non_git_dir(self, tmp_path: Path) -> None: + assert _has_origin_remote(tmp_path) is False assert _has_github_origin(tmp_path) is False @@ -118,8 +123,8 @@ def test_returns_false_for_non_git_dir(self, tmp_path: Path) -> None: class TestScanHomeForRepos: """Tests for scan_home_for_repos.""" - def test_finds_github_repos(self, tmp_path: Path) -> None: - # Create a repo with GitHub origin + def test_finds_repos_with_origin(self, tmp_path: Path) -> None: + # Create a repo with a non-GitHub origin repo = tmp_path / "my-project" repo.mkdir() subprocess.run(["git", "init", str(repo)], capture_output=True) @@ -131,7 +136,7 @@ def test_finds_github_repos(self, tmp_path: Path) -> None: "remote", "add", "origin", - "git@github.com:user/my-project.git", + "https://dev.azure.com/org/project/_git/my-project", ], capture_output=True, ) @@ -141,14 +146,10 @@ def test_finds_github_repos(self, tmp_path: Path) -> None: assert result[0]["path"] == str(repo.resolve()) assert result[0]["name"] == "my-project" - def test_skips_non_github_repos(self, tmp_path: Path) -> None: - repo = tmp_path / "gitlab-proj" + def test_skips_repos_without_origin(self, tmp_path: Path) -> None: + repo = tmp_path / "local-proj" repo.mkdir() subprocess.run(["git", "init", str(repo)], capture_output=True) - subprocess.run( - ["git", "-C", str(repo), "remote", "add", "origin", "https://gitlab.com/user/repo.git"], - capture_output=True, - ) result = scan_home_for_repos(tmp_path) assert len(result) == 0 diff --git a/tests/unit/mcp/tools/test_brownfield_handler.py b/tests/unit/mcp/tools/test_brownfield_handler.py index c81edc9aa..dbc94659a 100644 --- a/tests/unit/mcp/tools/test_brownfield_handler.py +++ b/tests/unit/mcp/tools/test_brownfield_handler.py @@ -365,7 +365,6 @@ async def test_query_empty_repos_suggests_scan(self) -> None: assert result.is_ok assert "scan" in result.value.text_content.lower() - # ── Pagination tests ──────────────────────────────────────────────