diff --git a/README.md b/README.md index 4924dcc..33ee40a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ In a distributed environment (Laptop ↔ Desktop), state drift is inevitable. * **Smart Identity:** Automatically detects naming collisions with other devices on the remote, ensuring unique backup streams for every machine. * **Out-of-Band Indexing:** Backups are stored in a configured namespace (default: `refs/heads/wip/pulsar/...`). Your `git status`, `git branch`, and `git log` remain completely clean. * **Distributed Sessions:** Hop between machines. Pulsar tracks sessions per device and lets you `sync` to pick up exactly where you left off. +* **State-Aware Diagnostics:** The `doctor` command correlates transient log events with active system health to prevent alert fatigue, and proactively scans for pipeline blockers like strict git hooks or broken `systemd` configurations. * **Zero-Interference:** * Uses a temporary index so it never messes up your partial `git add`. * Detects if you are rebasing or merging and waits for you to finish. @@ -176,7 +177,7 @@ This bootstraps the current directory with: ### Maintenance | Command | Description | | :--- | :--- | -| `git pulsar doctor` | Run deep diagnostics (logs, stuck repos) and clean up the registry. | +| `git pulsar doctor` | Run state-aware diagnostics (logs, repo health, drift detection, hook interference) and clean up the registry. | | `git pulsar prune` | Delete old backup history (>30 days). Runs automatically weekly. | | `git pulsar log` | View recent log history (last 1000 lines) and tail new entries. | @@ -227,7 +228,7 @@ ignore = ["*.tmp", "node_modules/"] *Focus: Leveraging data to make the tool feel alive and aware of your workflow.* - [ ] **Semantic Shadow Logs:** Replace generic "Shadow backup" messages with auto-generated summaries (e.g., `backup: modified daemon.py (+15 lines)`). -- [ ] **Roaming Radar:** Proactively detect if a different machine has pushed newer work to the same branch and notify the user to `sync`. +- [x] **Roaming Radar:** Proactively detect if a different machine has pushed newer work to the same branch and notify the user to `sync`. - [ ] **Decaying Retention:** Implement "Grandfather-Father-Son" pruning (keep all hourly backups for 24h, then daily summaries) to balance safety with disk space. ### Phase 3: The "TUI" Experience (Visuals) diff --git a/src/README.md b/src/README.md index 39a7f9f..cdad75f 100644 --- a/src/README.md +++ b/src/README.md @@ -30,9 +30,9 @@ The `src/` directory contains the package source code. The architecture strictly * **Logic:** Generates and registers `systemd` user timers (Linux) or instructions for `launchd` (macOS/Homebrew). ### 4. The Interface -* **`git_pulsar/cli.py`**: The User Entry Point. - * **Role:** Argument parsing and UI rendering. - * **Tech:** Uses `rich` for terminal visualization. It delegates all logic to `ops.py` or `daemon.py`. +* **`git_pulsar/cli.py`**: The User Entry Point & Diagnostic Engine. + * **Role:** Argument parsing, UI rendering, and system health evaluation. + * **Logic:** Uses `rich` for terminal visualization. Beyond routing subcommands to `ops.py` and `daemon.py`, it houses the `doctor` logic. It correlates repository state against transient event logs, detects topological drift across distributed sessions, and scans for host-environment pipeline blockers (e.g., strict git hooks, missing `systemd` linger). --- @@ -42,3 +42,4 @@ The `src/` directory contains the package source code. The architecture strictly 2. **Zero-Destruction:** The `prune` logic in `ops.py` relies on strictly namespaced refspecs (`refs/heads/wip/pulsar/...`) and never touches standard heads. 3. **Identity Stability:** The `system` module guarantees that a Machine ID persists across reboots, preventing "Split Brain" backup histories. 4. **Configuration Precedence:** Local project configuration MUST always override global user settings to ensure repo-specific constraints (e.g., large file limits) are respected. +5. **State Over Events:** The diagnostic engine (`cli.py`) MUST prioritize current repository and environmental state over historical log events to prevent alert fatigue from self-healing anomalies. diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index a03bab0..aa6d552 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -16,6 +16,7 @@ from .config import CONFIG_FILE, Config from .constants import ( APP_NAME, + BACKUP_NAMESPACE, DEFAULT_IGNORES, LOG_FILE, PID_FILE, @@ -39,12 +40,12 @@ def _get_ref(repo: GitRepo) -> str: return ops.get_backup_ref(repo.current_branch()) -def _analyze_logs(hours: int = 24) -> list[str]: +def _analyze_logs(seconds: int = 86400) -> list[str]: """ Scans the daemon log for error messages that occurred within a recent time window. Args: - hours (int, optional): The number of hours to look back. Defaults to 24. + seconds (int, optional): The number of seconds to look back. Defaults to 86400 (24h). Returns: list[str]: A list of error or critical log lines found within the time window. @@ -53,7 +54,7 @@ def _analyze_logs(hours: int = 24) -> list[str]: return [] errors = [] - threshold = datetime.datetime.now() - datetime.timedelta(hours=hours) + threshold = datetime.datetime.now() - datetime.timedelta(seconds=seconds) try: # Read the last 50KB of the log file @@ -355,6 +356,146 @@ def unregister_repo() -> None: console.print(f"✔ Unregistered: [cyan]{cwd}[/cyan]", style="green") +def _check_systemd_linger() -> str | None: + """Checks if systemd linger is enabled for the current Linux user. + + Returns: + str | None: A warning message if linger is disabled, or None if + enabled, or if the system is not Linux. + """ + if not sys.platform.startswith("linux"): + return None + + user = os.environ.get("USER") + if not user: + return None + + try: + res = subprocess.run( + ["loginctl", "show-user", user, "-p", "Linger"], + capture_output=True, + text=True, + timeout=2, + ) + if "Linger=yes" not in res.stdout: + return ( + "systemd 'linger' is disabled. Daemon will die when you log out. " + "Run 'loginctl enable-linger' to fix." + ) + except Exception as e: + logger.debug(f"Failed to check systemd linger status: {e}") + + return None + + +def _check_remote_drift(repo_path: Path) -> str | None: + """Checks if another machine has a newer backup session for the current branch. + + Args: + repo_path (Path): Path to the local git repository. + + Returns: + str | None: A warning message if drift is detected, otherwise None. + """ + try: + repo = GitRepo(repo_path) + current_branch = repo.current_branch() + if not current_branch: + return None + + # Lightweight fetch of backup refs for the current branch + try: + repo._run( + [ + "fetch", + "origin", + f"refs/heads/{BACKUP_NAMESPACE}/*/{current_branch}:refs/heads/{BACKUP_NAMESPACE}/*/{current_branch}", + ], + capture=True, + ) + except Exception as e: + logger.debug(f"Fetch failed during drift check: {e}") + return None # Silently fail if offline or remote is unreachable + + candidates = repo.list_refs(f"refs/heads/{BACKUP_NAMESPACE}/*/{current_branch}") + if not candidates: + return None + + my_slug = system.get_identity_slug() + my_backup_ref = ops.get_backup_ref(current_branch) + + # Determine our local latest timestamp (backup ref or HEAD) + local_ts = 0 + try: + if my_backup_ref in candidates: + local_ts = int( + repo._run(["log", "-1", "--format=%ct", my_backup_ref]).strip() + ) + else: + local_ts = int(repo._run(["log", "-1", "--format=%ct", "HEAD"]).strip()) + except Exception as e: + logger.debug(f"Failed to get local timestamp: {e}") + + newest_ts = 0 + newest_machine = "" + # Dynamically calculate the machine index in the ref string + machine_index = 2 + len(BACKUP_NAMESPACE.split("/")) + + for ref in candidates: + try: + ts = int(repo._run(["log", "-1", "--format=%ct", ref]).strip()) + if ts > newest_ts: + newest_ts = ts + parts = ref.split("/") + if len(parts) > machine_index: + newest_machine = parts[machine_index] + except Exception as e: + logger.debug(f"Failed to process ref {ref}: {e}") + continue + + if newest_ts > local_ts and newest_machine and newest_machine != my_slug: + minutes_ago = int((time.time() - newest_ts) / 60) + return ( + f"Divergence Risk: '{newest_machine}' pushed a newer session " + f"~{minutes_ago} mins ago. Consider running 'git pulsar sync'." + ) + except Exception as e: + logger.debug(f"Drift check failed: {e}") + + return None + + +def _check_git_hooks(repo_path: Path) -> list[str]: + """Scans the repository for executable git hooks that might block the daemon. + + Args: + repo_path (Path): Path to the local git repository. + + Returns: + list[str]: A list of warning messages regarding potentially blocking hooks. + """ + warnings: list[str] = [] + hooks_dir = repo_path / ".git" / "hooks" + + if not hooks_dir.exists(): + return warnings + + for hook in ["pre-commit", "pre-push"]: + hook_path = hooks_dir / hook + if hook_path.exists() and os.access(hook_path, os.X_OK): + try: + content = hook_path.read_text(errors="ignore") + if "pulsar" not in content.lower(): + warnings.append( + f"Strict '{hook}' hook detected. If it runs tests/linters, " + f"ensure it explicitly bypasses '{BACKUP_NAMESPACE}'." + ) + except Exception as e: + logger.debug(f"Failed to read {hook} hook for {repo_path.name}: {e}") + + return warnings + + def run_doctor() -> None: """ Diagnoses system health, cleans the registry, and checks connectivity and logs. @@ -388,6 +529,10 @@ def run_doctor() -> None: with console.status("[bold blue]Checking Daemon...", spinner="dots"): if service.is_service_enabled(): console.print(" [green]✔ Daemon is active.[/green]") + + # Sub-check: Systemd Linger on Linux + if linger_warning := _check_systemd_linger(): + console.print(f" [yellow]⚠ {linger_warning}[/yellow]") else: console.print( " [red]✘ Daemon is STOPPED.[/red] Run 'git pulsar install-service'." @@ -413,23 +558,24 @@ def run_doctor() -> None: except Exception as e: console.print(f" [red]✘ SSH Check failed: {e}[/red]") + # Check for remote session drift (if currently in a registered repository). + cwd = Path.cwd() + if (cwd / ".git").exists() and cwd in system.get_registered_repos(): + with console.status( + "[bold blue]Checking Remote Session Drift...", spinner="dots" + ): + if drift_warning := _check_remote_drift(cwd): + console.print(f" [yellow]⚠ {drift_warning}[/yellow]") + else: + console.print( + " [green]✔ Local session is up-to-date with remote.[/green]" + ) + # Perform diagnostics on logs and repository freshness. console.print("\n[bold]Diagnostics[/bold]") - # Check logs for recent errors. - recent_errors = _analyze_logs(hours=24) - if recent_errors: - console.print( - f" [red]✘ Found {len(recent_errors)} errors in the last 24h:[/red]" - ) - for err in recent_errors[-3:]: # Show last 3 - console.print(f" [dim]{err}[/dim]") - if len(recent_errors) > 3: - console.print(" ... (run 'git pulsar log' to see full history)") - else: - console.print(" [green]✔ Recent logs are clean.[/green]") - - # Check the health of registered repositories (Pulse Check). + # 1. Check the health of registered repositories (State Check + Hook Interference). + is_healthy = True with console.status("[bold blue]Checking Repository Health...", spinner="dots"): if REGISTRY_FILE.exists(): with open(REGISTRY_FILE) as f: @@ -437,12 +583,17 @@ def run_doctor() -> None: issues = [] for p in paths: - if p.exists() and (problem := _check_repo_health(p)): - issues.append(f"{p.name}: {problem}") + if p.exists(): + if problem := _check_repo_health(p): + issues.append(f"{p.name}: {problem}") + + for hook_warning in _check_git_hooks(p): + issues.append(f"{p.name} (Hook): {hook_warning}") if issues: + is_healthy = False console.print( - f" [yellow]⚠ Found {len(issues)} stalled repository(s):[/yellow]" + f" [yellow]⚠ Found {len(issues)} repository issue(s):[/yellow]" ) for issue in issues: console.print(f" - {issue}") @@ -456,6 +607,33 @@ def run_doctor() -> None: "(clean or backed up).[/green]" ) + # 2. Check logs for recent errors using dynamic window (Event Check). + conf = Config.load() + lookback_secs = conf.daemon.push_interval * 3 + recent_errors = _analyze_logs(seconds=lookback_secs) + + # 3. Correlate State and Events + if recent_errors: + lookback_hours = lookback_secs // 3600 + time_str = f"{lookback_hours}h" if lookback_hours > 0 else f"{lookback_secs}s" + + if is_healthy: + console.print( + f" [dim]ℹ {len(recent_errors)} transient error(s) logged in the last " + f"{time_str}, but system automatically recovered.[/dim]" + ) + else: + console.print( + f" [red]✘ Found {len(recent_errors)} active error(s) in the last " + f"{time_str}:[/red]" + ) + for err in recent_errors[-3:]: # Show last 3 + console.print(f" [dim]{err}[/dim]") + if len(recent_errors) > 3: + console.print(" ... (run 'git pulsar log' to see full history)") + else: + console.print(" [green]✔ Recent logs are clean.[/green]") + def add_ignore_cli(pattern: str) -> None: """Adds a file pattern to the repository's ignore list. diff --git a/tests/README.md b/tests/README.md index cd4ee39..ade2e77 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,6 +29,12 @@ Ensures the **Cascading Configuration** system behaves deterministically. * **Priority Resolution:** Verifies that Local config (`pulsar.toml`) overrides Global config (`config.toml`), and list values (like `ignore`) are appended rather than replaced. * **Preset Logic:** Tests that abstract presets (e.g., `paranoid`, `lazy`) correctly expand into concrete integer intervals for the daemon. +### 6. Diagnostics & CLI Interaction (`test_cli.py`) +Validates the state-aware diagnostic engine and user-facing CLI commands. +* **State vs. Event Correlation:** Tests the `doctor` command by decoupling repository health (state) from daemon logs (events). We mock dynamic lookback windows to verify that naturally resolved transient anomalies are suppressed, while active correlated failures trigger alerts. +* **Environment Simulation:** Uses `tmp_path` and `mocker` to synthesize restrictive `.git/hooks`, detached HEAD states, offline networks, and Linux `systemd` configurations (`loginctl`) without executing side effects on the host. +* **UI Determinism:** Ensures commands like `status` and `config` parse timestamps and route to standard system editors (`$EDITOR`, `nano`) correctly. + --- ## Running Tests diff --git a/tests/test_cli.py b/tests/test_cli.py index e6f350f..1fd563f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """Tests for the Command Line Interface (CLI) module.""" from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest @@ -108,3 +109,384 @@ def test_setup_repo_triggers_identity_config(tmp_path: Path, mocker: MagicMock) mock_config_id.assert_called_once() args = mock_config_id.call_args[0] assert isinstance(args[0], cli.GitRepo) + + +def test_check_systemd_linger_non_linux(mocker: MagicMock) -> None: + """Verifies that the linger check safely ignores non-Linux platforms. + + Args: + mocker (MagicMock): Pytest fixture for mocking. + """ + mocker.patch("sys.platform", "darwin") + result = cli._check_systemd_linger() + assert result is None + + +def test_check_systemd_linger_no_user(mocker: MagicMock) -> None: + """Verifies that the linger check aborts if the USER env var is missing. + + Args: + mocker (MagicMock): Pytest fixture for mocking. + """ + mocker.patch("sys.platform", "linux") + mocker.patch.dict("os.environ", clear=True) + + result = cli._check_systemd_linger() + assert result is None + + +def test_check_systemd_linger_enabled(mocker: MagicMock) -> None: + """Verifies that no warning is issued if Linger=yes is detected. + + Args: + mocker (MagicMock): Pytest fixture for mocking. + """ + mocker.patch("sys.platform", "linux") + mocker.patch.dict("os.environ", {"USER": "astro_dev"}) + + mock_run = mocker.patch("subprocess.run") + mock_run.return_value = mocker.MagicMock(stdout="Linger=yes\n") + + result = cli._check_systemd_linger() + + mock_run.assert_called_once_with( + ["loginctl", "show-user", "astro_dev", "-p", "Linger"], + capture_output=True, + text=True, + timeout=2, + ) + assert result is None + + +def test_check_systemd_linger_disabled(mocker: MagicMock) -> None: + """Verifies that a warning is returned if Linger=no is detected. + + Args: + mocker (MagicMock): Pytest fixture for mocking. + """ + mocker.patch("sys.platform", "linux") + mocker.patch.dict("os.environ", {"USER": "astro_dev"}) + + mock_run = mocker.patch("subprocess.run") + mock_run.return_value = mocker.MagicMock(stdout="Linger=no\n") + + result = cli._check_systemd_linger() + assert result is not None + assert "disabled" in result + assert "loginctl enable-linger" in result + + +def test_check_systemd_linger_exception(mocker: MagicMock) -> None: + """Verifies that the linger check fails gracefully on subprocess errors. + + Args: + mocker (MagicMock): Pytest fixture for mocking. + """ + mocker.patch("sys.platform", "linux") + mocker.patch.dict("os.environ", {"USER": "astro_dev"}) + + mock_run = mocker.patch("subprocess.run") + mock_run.side_effect = FileNotFoundError("loginctl not found") + + result = cli._check_systemd_linger() + assert result is None + + +def test_check_remote_drift_no_branch(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that drift detection aborts if the repository is in a detached HEAD state. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + mock_cls = mocker.patch("git_pulsar.cli.GitRepo") + repo = mock_cls.return_value + repo.current_branch.return_value = "" + + result = cli._check_remote_drift(tmp_path) + assert result is None + + +def test_check_remote_drift_fetch_fails(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that drift detection fails gracefully when offline. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + mock_cls = mocker.patch("git_pulsar.cli.GitRepo") + repo = mock_cls.return_value + repo.current_branch.return_value = "main" + repo._run.side_effect = Exception("Network offline") + + result = cli._check_remote_drift(tmp_path) + assert result is None + + +def test_check_remote_drift_local_is_newer(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that no warning is issued when the local session is the most recent. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + mock_cls = mocker.patch("git_pulsar.cli.GitRepo") + repo = mock_cls.return_value + repo.current_branch.return_value = "main" + + mocker.patch("git_pulsar.system.get_identity_slug", return_value="laptop--123") + mocker.patch( + "git_pulsar.ops.get_backup_ref", + return_value="refs/heads/wip/pulsar/laptop--123/main", + ) + + repo.list_refs.return_value = [ + "refs/heads/wip/pulsar/desktop--456/main", + "refs/heads/wip/pulsar/laptop--123/main", + ] + + def mock_run_side_effect(cmd: list[str], **kwargs: Any) -> str: + if cmd[0] == "fetch": + return "" + if cmd[0] == "log": + # Local is 2000, remote is 1000 + if "desktop" in cmd[-1]: + return "1000" + if "laptop" in cmd[-1]: + return "2000" + return "0" + + repo._run.side_effect = mock_run_side_effect + + result = cli._check_remote_drift(tmp_path) + assert result is None + + +def test_check_remote_drift_remote_is_newer(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that a warning is issued when another machine has a newer backup stream. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + mock_cls = mocker.patch("git_pulsar.cli.GitRepo") + repo = mock_cls.return_value + repo.current_branch.return_value = "main" + + mocker.patch("git_pulsar.system.get_identity_slug", return_value="laptop--123") + mocker.patch( + "git_pulsar.ops.get_backup_ref", + return_value="refs/heads/wip/pulsar/laptop--123/main", + ) + + repo.list_refs.return_value = [ + "refs/heads/wip/pulsar/desktop--456/main", + "refs/heads/wip/pulsar/laptop--123/main", + ] + + def mock_run_side_effect(cmd: list[str], **kwargs: Any) -> str: + if cmd[0] == "fetch": + return "" + if cmd[0] == "log": + # Remote is 2000, Local is 1000 + if "desktop" in cmd[-1]: + return "2000" + if "laptop" in cmd[-1]: + return "1000" + return "0" + + repo._run.side_effect = mock_run_side_effect + + # Mock time.time() to simulate 15 minutes since the remote commit (2000 + 900) + mocker.patch("time.time", return_value=2900.0) + + result = cli._check_remote_drift(tmp_path) + assert result is not None + assert "desktop--456" in result + assert "15 mins" in result + + +def test_check_git_hooks_no_dir(tmp_path: Path) -> None: + """Verifies that the hook check passes silently if no hooks directory exists. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + """ + (tmp_path / ".git").mkdir() + warnings = cli._check_git_hooks(tmp_path) + assert len(warnings) == 0 + + +def test_check_git_hooks_non_executable(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that non-executable hooks are safely ignored. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + hooks_dir = tmp_path / ".git" / "hooks" + hooks_dir.mkdir(parents=True) + hook_file = hooks_dir / "pre-push" + hook_file.write_text("exit 1") + + # Mock os.access to simulate a file lacking the +x bit + mocker.patch("os.access", return_value=False) + + warnings = cli._check_git_hooks(tmp_path) + assert len(warnings) == 0 + + +def test_check_git_hooks_with_bypass(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that executable hooks containing the 'pulsar' bypass keyword are ignored. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + hooks_dir = tmp_path / ".git" / "hooks" + hooks_dir.mkdir(parents=True) + hook_file = hooks_dir / "pre-commit" + + # Write a hook that includes the 'pulsar' keyword + script_content = "#!/bin/sh\nif [[ $1 == *pulsar* ]]; then exit 0; fi\nmake test" + hook_file.write_text(script_content) + + # Force os.access to treat the file as executable + mocker.patch("os.access", return_value=True) + + warnings = cli._check_git_hooks(tmp_path) + assert len(warnings) == 0 + + +def test_check_git_hooks_strict_blocking(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that strict, executable hooks trigger a warning. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + hooks_dir = tmp_path / ".git" / "hooks" + hooks_dir.mkdir(parents=True) + + # Create two blocking hooks + for hook in ["pre-commit", "pre-push"]: + hook_file = hooks_dir / hook + hook_file.write_text(f"#!/bin/sh\necho 'Running strict {hook} linters'") + + mocker.patch("os.access", return_value=True) + + warnings = cli._check_git_hooks(tmp_path) + + # We should get a warning for each strict hook + assert len(warnings) == 2 + assert "Strict 'pre-commit' hook detected" in warnings[0] + assert "Strict 'pre-push' hook detected" in warnings[1] + + +def test_run_doctor_transient_error_suppression( + tmp_path: Path, mocker: MagicMock +) -> None: + """Verifies that transient log errors are suppressed when the system state is healthy. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + # 1. Mock Registry & File System using tmp_path + mock_repo = tmp_path / "mock_repo" + mock_repo.mkdir() + + mock_registry = tmp_path / "registry" + mock_registry.write_text(f"{mock_repo}\n") + + mocker.patch("git_pulsar.system.get_registered_repos", return_value=[mock_repo]) + mocker.patch("git_pulsar.cli.REGISTRY_FILE", mock_registry) + + # 2. Mock environment sub-checks + mocker.patch("git_pulsar.service.is_service_enabled", return_value=True) + mocker.patch("git_pulsar.cli._check_systemd_linger", return_value=None) + mocker.patch( + "subprocess.run", + return_value=mocker.MagicMock(stderr="successfully authenticated"), + ) + mocker.patch("git_pulsar.cli._check_remote_drift", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + + # 3. Mock State & Event Correlation inputs + # State is healthy (None returned from health check) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + + mock_conf = mocker.MagicMock() + mock_conf.daemon.push_interval = 3600 + mocker.patch("git_pulsar.config.Config.load", return_value=mock_conf) + + # Events exist but state is healthy -> Transient + mocker.patch( + "git_pulsar.cli._analyze_logs", return_value=["Transient connection drop"] + ) + + # 4. Mock the console to capture output formatting + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + # 5. Assert correlation correctly identified transient anomaly + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert "transient error(s) logged" in output + assert "automatically recovered" in output + + +def test_run_doctor_active_error_correlation(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that log errors are displayed loudly when the system state is failing. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + mocker (MagicMock): Pytest fixture for mocking. + """ + # 1. Mock Registry & File System using tmp_path + mock_repo = tmp_path / "mock_repo" + mock_repo.mkdir() + + mock_registry = tmp_path / "registry" + mock_registry.write_text(f"{mock_repo}\n") + + mocker.patch("git_pulsar.system.get_registered_repos", return_value=[mock_repo]) + mocker.patch("git_pulsar.cli.REGISTRY_FILE", mock_registry) + + # 2. Mock environment sub-checks + mocker.patch("git_pulsar.service.is_service_enabled", return_value=True) + mocker.patch("git_pulsar.cli._check_systemd_linger", return_value=None) + mocker.patch( + "subprocess.run", + return_value=mocker.MagicMock(stderr="successfully authenticated"), + ) + mocker.patch("git_pulsar.cli._check_remote_drift", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + + # 3. State is UNHEALTHY + mocker.patch( + "git_pulsar.cli._check_repo_health", + return_value="Stalled: Changes pending > 2 hours.", + ) + + mock_conf = mocker.MagicMock() + mock_conf.daemon.push_interval = 3600 + mocker.patch("git_pulsar.config.Config.load", return_value=mock_conf) + + # Events exist and correlate with Unhealthy state + mocker.patch( + "git_pulsar.cli._analyze_logs", return_value=["Connection refused", "Timeout"] + ) + + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + # Assert correlation correctly escalated the errors + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert "active error(s) in the last" in output + assert "Connection refused" in output