Skip to content
Open
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
25 changes: 15 additions & 10 deletions src/ouroboros/bigbang/brownfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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 ``~/``.
Expand All @@ -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))
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/ouroboros/cli/commands/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 9 additions & 10 deletions src/ouroboros/mcp/tools/brownfield_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -91,15 +90,15 @@ 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=(
MCPToolParameter(
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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
27 changes: 14 additions & 13 deletions tests/unit/bigbang/test_brownfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ouroboros.bigbang.brownfield import (
_SKIP_DIRS,
BrownfieldEntry,
_has_origin_remote,
_has_github_origin,
_read_readme_content,
generate_desc,
Expand Down Expand Up @@ -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
Expand All @@ -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(
[
Expand All @@ -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


Expand All @@ -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)
Expand All @@ -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,
)
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion tests/unit/mcp/tools/test_brownfield_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────


Expand Down
Loading