Skip to content
Draft
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
Repository = "https://github.com/trobz/trobz_local"

[project.scripts]
tlc = "trobz_local.main:app"
tlc = "trobz_local.main:cli"

[dependency-groups]
dev = [
Expand Down
135 changes: 133 additions & 2 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CheckStatus,
check_config,
check_github_ssh,
check_repos,
check_system_tools,
check_tool_versions,
list_venvs,
Expand All @@ -27,7 +28,7 @@ def test_check_config_valid(tmp_path):
(tmp_path / "config.toml").write_text('versions = ["18.0"]\n\n[repos]\nodoo = ["odoo"]\n')
result = check_config(tmp_path)
assert result.status == CheckStatus.OK
assert "1 version" in result.message
assert "Valid" in result.message


def test_check_config_missing(tmp_path):
Expand Down Expand Up @@ -247,23 +248,26 @@ def test_list_venvs_empty_versions(tmp_path):
# ---------------------------------------------------------------------------


@patch("trobz_local.doctor.check_repos")
@patch("trobz_local.doctor.check_github_ssh")
@patch("trobz_local.doctor.check_system_tools")
@patch("trobz_local.doctor.check_tool_versions")
@patch("trobz_local.doctor.list_venvs")
def test_run_doctor_with_config(mock_venvs, mock_tools, mock_sys_tools, mock_ssh, tmp_path):
def test_run_doctor_with_config(mock_venvs, mock_tools, mock_sys_tools, mock_ssh, mock_repos, tmp_path):
(tmp_path / "config.toml").write_text('versions = ["18.0"]\n')
mock_ssh.return_value = CheckResult("GitHub SSH", CheckStatus.OK, "Authenticated")
mock_sys_tools.return_value = [CheckResult("git", CheckStatus.OK, "Found (git version 2.45.0)")]
mock_tools.return_value = []
mock_venvs.return_value = []
mock_repos.return_value = [CheckResult("odoo (18.0)", CheckStatus.OK, "up to date")]

groups = run_doctor(tmp_path)

assert "Configuration" in groups
assert "Connectivity" in groups
assert "Tools" in groups
assert "Virtual Environments" in groups
assert "Repositories" in groups
assert groups["Configuration"][0].status == CheckStatus.OK
# System tools always included
assert groups["Tools"][0].name == "git"
Expand Down Expand Up @@ -324,3 +328,130 @@ def test_doctor_command_warnings_exit_zero(mock_root, mock_doctor, tmp_path):
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "!!" in result.output


# ---------------------------------------------------------------------------
# check_repos
# ---------------------------------------------------------------------------


@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_not_cloned(mock_tasks, tmp_path):
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": tmp_path / "nonexistent", "version": "18.0"},
]
results = check_repos(tmp_path, {}, ["18.0"])
assert len(results) == 1
assert results[0].status == CheckStatus.FAIL
assert "not cloned" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_up_to_date(mock_tasks, mock_repo_cls, tmp_path):
repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo = MagicMock()
mock_repo.head.commit.hexsha = "abc123"
mock_repo.remotes.origin.refs.__getitem__.return_value.commit.hexsha = "abc123"
mock_repo.is_dirty.return_value = False
mock_repo_cls.return_value = mock_repo

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.OK
assert "up to date" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_behind(mock_tasks, mock_repo_cls, tmp_path):
repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo = MagicMock()
mock_repo.head.commit.hexsha = "abc123"
mock_repo.remotes.origin.refs.__getitem__.return_value.commit.hexsha = "def456"
mock_repo.is_dirty.return_value = False
mock_repo_cls.return_value = mock_repo

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.WARN
assert "behind upstream" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_dirty(mock_tasks, mock_repo_cls, tmp_path):
repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo = MagicMock()
mock_repo.head.commit.hexsha = "abc123"
mock_repo.remotes.origin.refs.__getitem__.return_value.commit.hexsha = "abc123"
mock_repo.is_dirty.return_value = True
mock_repo_cls.return_value = mock_repo

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.WARN
assert "dirty" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_behind_and_dirty(mock_tasks, mock_repo_cls, tmp_path):
repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo = MagicMock()
mock_repo.head.commit.hexsha = "abc123"
mock_repo.remotes.origin.refs.__getitem__.return_value.commit.hexsha = "def456"
mock_repo.is_dirty.return_value = True
mock_repo_cls.return_value = mock_repo

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.WARN
assert "behind upstream" in results[0].message
assert "dirty" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_fetch_error(mock_tasks, mock_repo_cls, tmp_path):
repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo = MagicMock()
mock_repo.remotes.origin.fetch.side_effect = Exception("network error")
mock_repo_cls.return_value = mock_repo

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.WARN
assert "fetch failed" in results[0].message


@patch("trobz_local.doctor.git.Repo")
@patch("trobz_local.doctor.get_repo_tasks")
def test_check_repos_invalid_repo(mock_tasks, mock_repo_cls, tmp_path):
import git as git_mod

repo_path = tmp_path / "odoo"
repo_path.mkdir()
mock_tasks.return_value = [
{"repo_name": "odoo", "repo_path": repo_path, "version": "18.0"},
]
mock_repo_cls.side_effect = git_mod.exc.InvalidGitRepositoryError("bad")

results = check_repos(tmp_path, {}, ["18.0"])
assert results[0].status == CheckStatus.FAIL
assert "invalid git repository" in results[0].message
10 changes: 5 additions & 5 deletions tests/test_pull_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from rich.progress import Progress, TaskID
from typer.testing import CliRunner

from trobz_local.main import ODOO_URLS, _get_tasks, _pull_repo
from trobz_local.utils import get_config
from trobz_local.main import _pull_repo
from trobz_local.utils import ODOO_URLS, get_config, get_repo_tasks

runner = CliRunner()

Expand Down Expand Up @@ -149,7 +149,7 @@ def test_get_tasks_generates_correct_list(mock_config, tmp_path):
repos_config = {"odoo": ["odoo"], "oca": ["server-tools"]}
code_root = tmp_path / "code"

tasks = _get_tasks(odoo_versions, repos_config, code_root, None)
tasks = get_repo_tasks(odoo_versions, repos_config, code_root, None)

expected_tasks = [
{
Expand Down Expand Up @@ -193,7 +193,7 @@ def test_get_tasks_with_filter(mock_config, tmp_path):
repos_config = {"odoo": ["odoo"], "oca": ["server-tools"]}
code_root = tmp_path / "code"

tasks = _get_tasks(odoo_versions, repos_config, code_root, repo_filter=["odoo"])
tasks = get_repo_tasks(odoo_versions, repos_config, code_root, repo_filter=["odoo"])

expected_tasks = [
{
Expand Down Expand Up @@ -222,7 +222,7 @@ def test_get_tasks_inline_branch_override(mock_config, tmp_path):
}
code_root = tmp_path / "code"

tasks = _get_tasks(odoo_versions, repos_config, code_root, None)
tasks = get_repo_tasks(odoo_versions, repos_config, code_root, None)

paths = {(t["repo_name"], t["version"]): t["repo_path"] for t in tasks}

Expand Down
2 changes: 2 additions & 0 deletions trobz_local/concurrency.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass

from rich.console import Console
from rich.progress import BarColumn, Progress, TextColumn


Expand All @@ -21,6 +22,7 @@ def run_tasks(tasks, max_workers: int = 4):
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=Console(stderr=True),
) as progress:
overall = progress.add_task(f"[cyan]0/{total} done", total=total)

Expand Down
61 changes: 57 additions & 4 deletions trobz_local/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from enum import Enum
from pathlib import Path

import git
import tomli
from pydantic import ValidationError

from .utils import ConfigModel
from .utils import ConfigModel, get_repo_tasks


class CheckStatus(Enum):
Expand Down Expand Up @@ -48,7 +49,7 @@ def check_config(code_root: Path) -> CheckResult:
)

try:
validated = ConfigModel(**raw)
ConfigModel(**raw)
except ValidationError as e:
errors = "; ".join(err["msg"] for err in e.errors())
return CheckResult(
Expand All @@ -58,11 +59,10 @@ def check_config(code_root: Path) -> CheckResult:
detail=errors,
)

version_count = len(validated.versions)
return CheckResult(
name="Config file",
status=CheckStatus.OK,
message=f"Valid — {version_count} version(s) defined",
message=f"Valid — Config file at {config_path}",
)


Expand Down Expand Up @@ -335,6 +335,52 @@ def list_venvs(code_root: Path, versions: list[str]) -> list[CheckResult]:
return results


def check_repos(code_root: Path, repos_config: dict, versions: list[str]) -> list[CheckResult]:
"""Check each configured repo's upstream status and dirty state."""
tasks = get_repo_tasks(versions, repos_config, code_root, repo_filter=None)
results = []
for task in tasks:
name = f"{task['repo_name']} ({task['version']})"
repo_path = task["repo_path"]
version = task["version"]

if not repo_path.exists():
results.append(CheckResult(name=name, status=CheckStatus.FAIL, message="not cloned"))
continue

try:
repo = git.Repo(repo_path)
except git.exc.InvalidGitRepositoryError:
results.append(CheckResult(name=name, status=CheckStatus.FAIL, message="invalid git repository"))
continue

# Fetch and compare SHAs
try:
repo.remotes.origin.fetch(version)
local_sha = repo.head.commit.hexsha
remote_sha = repo.remotes.origin.refs[version].commit.hexsha
behind = local_sha != remote_sha
except Exception as e:
results.append(CheckResult(name=name, status=CheckStatus.WARN, message=f"fetch failed: {e}"))
continue

dirty = repo.is_dirty(untracked_files=True)

if behind and dirty:
msg = "behind upstream, dirty"
elif behind:
msg = "behind upstream"
elif dirty:
msg = "dirty (uncommitted changes)"
else:
msg = "up to date"

status = CheckStatus.WARN if (behind or dirty) else CheckStatus.OK
results.append(CheckResult(name=name, status=status, message=msg))

return results


def run_doctor(code_root: Path) -> dict[str, list[CheckResult]]:
"""Run all health checks and return grouped results."""
groups: dict[str, list[CheckResult]] = {}
Expand Down Expand Up @@ -374,4 +420,11 @@ def run_doctor(code_root: Path) -> dict[str, list[CheckResult]]:
versions = config.versions if config else []
groups["Virtual Environments"] = list_venvs(code_root, versions)

# --- Repositories ---
if config:
repos_config = config.repos.model_dump()
groups["Repositories"] = check_repos(code_root, repos_config, versions)
else:
groups["Repositories"] = []

return groups
Loading