diff --git a/README.md b/README.md index 0e3ca4b..71da06d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ In a distributed environment (Laptop ↔ Desktop), state drift is inevitable. - **Roaming Radar:** The background daemon actively polls for topological drift, firing a cross-platform OS notification if another machine leapfrogs your local session so you can `sync` before conflicts arise. - **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. +- **State-Aware Diagnostics:** The `doctor` command correlates transient log events with active system health to prevent alert fatigue, proactively scans for pipeline blockers, and offers an interactive queue to safely auto-fix common issues. - **Active Observability:** The `status` dashboard provides zero-latency power telemetry (e.g., Eco-Mode throttling) and immediately surfaces cached warnings for remote session drift and oversized files. - **Zero-Interference:** - Uses a temporary index so it never messes up your partial `git add`. @@ -199,7 +199,7 @@ This bootstraps the current directory with: | Command | Description | | :--- | :--- | -| `git pulsar doctor` | Run state-aware diagnostics (logs, repo health, drift detection, hook interference) and clean up the registry. | +| `git pulsar doctor` | Run state-aware diagnostics and interactively auto-fix issues (logs, repo health, drift detection, hook interference). | | `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. | @@ -246,7 +246,7 @@ ignore = ["*.tmp", "node_modules/"] - [ ] **Smart Restore:** Replace hard failures on "dirty" files with a negotiation menu (Overwrite / View Diff / Cancel). - [ ] **Pre-Flight Checklists:** Display a summary table of incoming changes (machines, timestamps, file counts) before running destructive commands like `finalize`. -- [ ] **Active Doctor:** Upgrade `git pulsar doctor` to not just diagnose issues (like stopped daemons), but offer to auto-fix them interactively. +- [x] **Active Doctor:** Upgrade `git pulsar doctor` to not just diagnose issues (like stopped daemons), but offer to auto-fix them interactively. ### Phase 2: "Deep Thought" (Context & Intelligence) diff --git a/src/README.md b/src/README.md index eafac1a..eb6fb44 100644 --- a/src/README.md +++ b/src/README.md @@ -35,8 +35,8 @@ The `src/` directory contains the package source code. The architecture strictly ### 4. The Interface - **`git_pulsar/cli.py`**: The User Entry Point & Diagnostic Engine. - - **Role:** Argument parsing, UI rendering, real-time observability, and system health evaluation. - - **Logic:** Uses `rich` for terminal visualization. Beyond routing subcommands to `ops.py` and `daemon.py`, it presents the `doctor` diagnostics and the zero-latency `status` dashboard (surfacing power telemetry, dynamic health constraints, and cached drift warnings). It correlates repository state against transient event logs, and relies on `ops.py` to evaluate topological drift across distributed sessions and scan for host-environment pipeline blockers (e.g., strict git hooks, missing `systemd` linger). + - **Role:** Argument parsing, UI rendering, real-time observability, system health evaluation, and interactive issue resolution. + - **Logic:** Uses `rich` for terminal visualization. Beyond routing subcommands to `ops.py` and `daemon.py`, it presents the `doctor` diagnostics and the zero-latency `status` dashboard (surfacing power telemetry, dynamic health constraints, and cached drift warnings). It correlates repository state against transient event logs and executes a two-stage diagnostic pipeline: scanning for host-environment pipeline blockers (e.g., strict git hooks, missing `systemd` linger), followed by an interactive resolution queue that prompts users to safely auto-fix specific issues (like stale index locks or ghost registry entries) or provides precise terminal commands for manual interventions. --- @@ -47,3 +47,4 @@ The `src/` directory contains the package source code. The architecture strictly 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 (Zero-Latency):** The diagnostic engine (`cli.py`) MUST prioritize current repository state and local caches (e.g., `.git/pulsar_drift_state`) over historical log events or live network calls, ensuring the CLI never blocks the user's terminal while evaluating system health. +6. **Interactive Safety:** The diagnostic engine's interactive resolution queue MUST explicitly prompt the user for confirmation before executing any state-altering auto-fixes (e.g., deleting locks or modifying the registry). diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 7836fe6..0bbd1ed 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -5,10 +5,13 @@ import subprocess import sys import time +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from rich.console import Console from rich.panel import Panel +from rich.prompt import Confirm from rich.table import Table from rich.text import Text @@ -28,6 +31,22 @@ console = Console() +@dataclass +class DoctorAction: + """Represents an actionable resolution for an issue detected by the doctor. + + Attributes: + description (str): A brief summary of the issue to be resolved. + prompt (str): The yes/no question presented to the user. + action_callable (Callable[[], bool]): The function to execute if the user + confirms. Must return True if successful, False otherwise. + """ + + description: str + prompt: str + action_callable: Callable[[], bool] + + def _get_ref(repo: GitRepo) -> str: """Resolves the namespaced backup reference for the current repository state. @@ -469,8 +488,9 @@ def _check_git_hooks(repo_path: Path) -> list[str]: 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}'." + f"Strict '{hook}' hook detected.\n" + f" [dim]Action required: Append this line near the top of your hook to bypass it for backups:\n" + f' if [[ $GIT_REFLOG_ACTION == *"{BACKUP_NAMESPACE}"* ]]; then exit 0; fi[/dim]' ) except Exception as e: logger.debug(f"Failed to read {hook} hook for {repo_path.name}: {e}") @@ -481,9 +501,12 @@ def _check_git_hooks(repo_path: Path) -> list[str]: def run_doctor() -> None: """ Diagnoses system health, cleans the registry, and checks connectivity and logs. + Includes an interactive resolution queue for safe auto-fixes. """ console.print("[bold]Pulsar Doctor[/bold]\n") + actions: list[DoctorAction] = [] + # Verify and clean the registry. with console.status("[bold blue]Checking Registry...", spinner="dots"): repos = system.get_registered_repos() @@ -491,18 +514,33 @@ def run_doctor() -> None: console.print(" [green]✔ Registry empty/clean.[/green]") else: valid_lines = [] - fixed = False + missing_paths = [] for p in repos: if p.exists(): valid_lines.append(str(p)) else: - fixed = True + missing_paths.append(str(p)) - if fixed: - with open(REGISTRY_FILE, "w") as f: - f.write("\n".join(valid_lines) + "\n") + if missing_paths: console.print( - " [green]✔ Registry cleaned (ghost entries removed).[/green]" + f" [yellow]⚠ Found {len(missing_paths)} missing registry entries.[/yellow]" + ) + + def clean_registry() -> bool: + try: + with open(REGISTRY_FILE, "w") as f: + f.write("\n".join(valid_lines) + "\n") + return True + except Exception as e: + logger.error(f"Registry cleanup failed: {e}") + return False + + actions.append( + DoctorAction( + description=f"Remove {len(missing_paths)} ghost entries from registry", + prompt=f"Found {len(missing_paths)} missing paths. Remove from registry?", + action_callable=clean_registry, + ) ) else: console.print(" [green]✔ Registry healthy.[/green]") @@ -515,9 +553,42 @@ def run_doctor() -> None: # Sub-check: Systemd Linger on Linux if linger_warning := _check_systemd_linger(): console.print(f" [yellow]⚠ {linger_warning}[/yellow]") + + def enable_linger() -> bool: + try: + user = os.environ.get("USER") + if not user: + return False + subprocess.run(["loginctl", "enable-linger", user], check=True) + return True + except Exception as e: + logger.error(f"Failed to enable linger: {e}") + return False + + actions.append( + DoctorAction( + description="Enable systemd user linger", + prompt="Enable background lingering? (Runs: loginctl enable-linger $USER)", + action_callable=enable_linger, + ) + ) else: - console.print( - " [red]✘ Daemon is STOPPED.[/red] Run 'git pulsar install-service'." + console.print(" [red]✘ Daemon is STOPPED.[/red]") + + def install_daemon() -> bool: + try: + service.install(interval=900) + return True + except Exception as e: + logger.error(f"Failed to install daemon: {e}") + return False + + actions.append( + DoctorAction( + description="Install and start the background daemon", + prompt="Daemon is stopped. Install the background service?", + action_callable=install_daemon, + ) ) # Check network/SSH connectivity. @@ -534,11 +605,15 @@ def run_doctor() -> None: else: console.print( " [yellow]⚠ GitHub SSH check returned " - "unexpected response.[/yellow]" + "unexpected response.[/yellow]\n" + " [dim]Action required: Verify SSH key configuration or remote permissions.[/dim]" ) except Exception as e: console.print(f" [red]✘ SSH Check failed: {e}[/red]") + console.print( + " [dim]Action required: Check your network connection or run 'ssh-add' to load your keys.[/dim]" + ) # Check for remote session drift (if currently in a registered repository). cwd = Path.cwd() @@ -549,6 +624,25 @@ def run_doctor() -> None: drift_detected, _, _, drift_warning = ops.get_remote_drift_state(cwd) if drift_detected: console.print(f" [yellow]⚠ {drift_warning}[/yellow]") + + def sync_drift() -> bool: + try: + # ops.sync_session handles its own UI and uses sys.exit + ops.sync_session() + return True + except SystemExit as e: + return e.code == 0 + except Exception as e: + logger.error(f"Failed to sync session: {e}") + return False + + actions.append( + DoctorAction( + description="Sync local session with remote", + prompt="Run 'git pulsar sync' to reconcile remote session?", + action_callable=sync_drift, + ) + ) else: console.print( " [green]✔ Local session is up-to-date with remote.[/green]" @@ -568,6 +662,70 @@ def run_doctor() -> None: for p in paths: if p.exists(): repo_config = Config.load(p) + + # 1a. Check for paused state + pause_file = p / ".git" / "pulsar_paused" + if pause_file.exists(): + issues.append(f"{p.name}: Repository is explicitly paused.") + + def resume_repo(path_to_unpause: Path = pause_file) -> bool: + try: + path_to_unpause.unlink(missing_ok=True) + return True + except OSError as e: + logger.error(f"Failed to resume {path_to_unpause}: {e}") + return False + + actions.append( + DoctorAction( + description=f"Resume backups for {p.name}", + prompt=f"Resume backups for {p.name}?", + action_callable=resume_repo, + ) + ) + + # 1b. Check for stale index lock + lock_file = p / ".git" / "index.lock" + if lock_file.exists(): + try: + mtime = lock_file.stat().st_mtime + age_hours = (time.time() - mtime) / 3600 + if age_hours > 2: + issues.append( + f"{p.name}: Stale index lock found ({age_hours:.1f}h old)." + ) + + def remove_lock(path_to_lock: Path = lock_file) -> bool: + try: + path_to_lock.unlink(missing_ok=True) + return True + except OSError as e: + logger.error( + f"Failed to remove lock at {path_to_lock}: {e}" + ) + return False + + actions.append( + DoctorAction( + description=f"Remove stale index lock in {p.name}", + prompt=f"Stale index lock found in {p.name} ({age_hours:.1f}h old). Remove it?", + action_callable=remove_lock, + ) + ) + except OSError: + pass # Lock file vanished during read (race resolved) + + # 1c. Large file check (Manual Intervention) + if ops.has_large_files(p, repo_config): + limit_mb = int( + repo_config.limits.large_file_threshold / (1024 * 1024) + ) + issues.append( + f"{p.name}: File >{limit_mb}MB detected, blocking backups.\n" + f" [dim]Action required: Untrack the file or run 'git pulsar ignore '[/dim]" + ) + + # 1d. Standard health check if problem := _check_repo_health(p, repo_config): issues.append(f"{p.name}: {problem}") @@ -582,8 +740,7 @@ def run_doctor() -> None: for issue in issues: console.print(f" - {issue}") console.print( - " [dim](Check if daemon is running or " - "if files are too large)[/dim]" + " [dim](Ensure daemon is running and review required actions above)[/dim]" ) else: console.print( @@ -618,6 +775,54 @@ def run_doctor() -> None: else: console.print(" [green]✔ Recent logs are clean.[/green]") + # --- Interactive Resolution Phase --- + if actions: + console.print("\n[bold]Interactive Resolutions[/bold]") + for action in actions: + if Confirm.ask(f" {action.prompt}"): + try: + if action.action_callable(): + console.print( + f" [green]✔ Resolved:[/green] {action.description}" + ) + else: + console.print( + f" [red]✘ Failed to resolve:[/red] {action.description}" + ) + except Exception as e: + console.print( + f" [red]✘ Error resolving {action.description}:[/red] {e}" + ) + else: + console.print(" [dim]Skipped.[/dim]") + + # 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 0886a16..956af18 100644 --- a/tests/README.md +++ b/tests/README.md @@ -48,8 +48,9 @@ Ensures the **Cascading Configuration** system behaves deterministically. Validates the state-aware diagnostic engine and user-facing CLI commands. - **Dashboard Observability:** Validates the `status` command's rendering of power telemetry (Eco-Mode vs. Critical), dynamic health thresholds, and zero-latency caching for drift/blocker warnings. +- **Interactive Resolution Queue:** Tests the `doctor` command's two-stage pipeline, ensuring execution loops correctly apply confirmed auto-fixes (e.g., stale index lock removal, ghost registry cleanup) and safely bypass declined ones. - **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`, offline networks, and Linux `systemd` configurations (`loginctl`) without executing side effects on the host. +- **Environment Simulation & Guidance:** Uses `tmp_path` and `mocker` to synthesize restrictive `.git/hooks`, offline networks, and Linux `systemd` configurations (`loginctl`) without executing side effects on the host, verifying exact stdout formatting for manual interventions. - **UI Determinism:** Ensures commands like `status` and `config` parse timestamps and route to standard system editors (`$EDITOR`, `nano`) correctly. --- diff --git a/tests/test_cli.py b/tests/test_cli.py index cf29336..2dba360 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ """Tests for the Command Line Interface (CLI) module.""" +import os import time from pathlib import Path from unittest.mock import MagicMock @@ -527,3 +528,283 @@ def test_run_doctor_active_error_correlation(tmp_path: Path, mocker: MagicMock) ) assert "active error(s) in the last" in output assert "Connection refused" in output + + +# Test run_doctor's interactive loop + + +def test_run_doctor_executes_confirmed_actions( + tmp_path: Path, mocker: MagicMock +) -> None: + """Verifies that the interactive loop executes the closure when confirmed.""" + mock_repo = tmp_path / "mock_repo" + git_dir = mock_repo / ".git" + git_dir.mkdir(parents=True) + pause_file = git_dir / "pulsar_paused" + pause_file.touch() + + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + assert not pause_file.exists() + + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert "✔ Resolved:" in output + assert "Resume backups for mock_repo" in output + + +def test_run_doctor_skips_declined_actions(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that the interactive loop bypasses the closure when declined.""" + mock_repo = tmp_path / "mock_repo" + git_dir = mock_repo / ".git" + git_dir.mkdir(parents=True) + pause_file = git_dir / "pulsar_paused" + pause_file.touch() + + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=False) + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + assert pause_file.exists() + + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert "[dim]Skipped.[/dim]" in output + + +def test_run_doctor_fixes_stale_index_lock(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that locks older than 2 hours prompt a resolution action.""" + mock_repo = tmp_path / "mock_repo" + git_dir = mock_repo / ".git" + git_dir.mkdir(parents=True) + lock_file = git_dir / "index.lock" + lock_file.touch() + + old_time = time.time() - (3 * 3600) + os.utime(lock_file, (old_time, old_time)) + + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + cli.run_doctor() + + assert not lock_file.exists() + + +def test_run_doctor_ignores_fresh_index_lock(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that fresh index locks do not trigger a resolution prompt.""" + mock_repo = tmp_path / "mock_repo" + git_dir = mock_repo / ".git" + git_dir.mkdir(parents=True) + lock_file = git_dir / "index.lock" + lock_file.touch() + + # Manipulate file mtime to be 5 minutes old + recent_time = time.time() - 300 + os.utime(lock_file, (recent_time, recent_time)) + + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + + mock_confirm = mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + cli.run_doctor() + + # Lock file should still exist, and Confirm.ask should not have been called + assert lock_file.exists() + mock_confirm.assert_not_called() + + +def test_run_doctor_cleans_ghost_registry(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies the registry cleanup action drops missing paths and preserves valid ones.""" + valid_repo = tmp_path / "valid_repo" + valid_repo.mkdir() + missing_repo = tmp_path / "missing_repo" + + registry_path = tmp_path / "registry" + registry_path.write_text(f"{valid_repo}\n{missing_repo}\n") + + mocker.patch( + "git_pulsar.system.get_registered_repos", + return_value=[valid_repo, missing_repo], + ) + mocker.patch("git_pulsar.cli.REGISTRY_FILE", registry_path) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + cli.run_doctor() + + # Verify registry content + registry_data = registry_path.read_text().splitlines() + assert str(valid_repo) in registry_data + assert str(missing_repo) not in registry_data + + +def test_run_doctor_triggers_sync_on_drift(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that detected session drift queues the sync_session closure.""" + mock_repo = tmp_path / "mock_repo" + (mock_repo / ".git").mkdir(parents=True) + + mocker.patch.object(Path, "cwd", return_value=mock_repo) + mocker.patch("git_pulsar.system.get_registered_repos", return_value=[mock_repo]) + mocker.patch("git_pulsar.cli.REGISTRY_FILE", tmp_path / "registry") + mocker.patch("git_pulsar.service.is_service_enabled", return_value=True) + mocker.patch("subprocess.run") + + # Mock drift detection + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", + return_value=(True, 9999, "remote_mac", "Drift detected!"), + ) + + mock_sync = mocker.patch("git_pulsar.ops.sync_session") + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + cli.run_doctor() + + mock_sync.assert_called_once() + + +def test_run_doctor_outputs_hook_bypass_snippet( + tmp_path: Path, mocker: MagicMock +) -> None: + """Verifies that strict hooks output the exact shell snippet needed to bypass them.""" + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + expected_snippet = 'if [[ $GIT_REFLOG_ACTION == *"wip/pulsar"* ]]; then exit 0; fi' + mocker.patch( + "git_pulsar.cli._check_git_hooks", + return_value=[f"Strict hook.\nAction required: Append...\n{expected_snippet}"], + ) + + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert expected_snippet in output + + +def test_run_doctor_outputs_large_file_action( + tmp_path: Path, mocker: MagicMock +) -> None: + """Verifies that large files output clear instructions on how to ignore them.""" + 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) + 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") + mocker.patch( + "git_pulsar.ops.get_remote_drift_state", return_value=(False, 0, "", "") + ) + mocker.patch("git_pulsar.cli._check_repo_health", return_value=None) + mocker.patch("git_pulsar.cli._check_git_hooks", return_value=[]) + mocker.patch("git_pulsar.cli._analyze_logs", return_value=[]) + mocker.patch("git_pulsar.cli.Confirm.ask", return_value=True) + + mocker.patch("git_pulsar.ops.has_large_files", return_value=True) + + mock_console = mocker.patch("git_pulsar.cli.console") + + cli.run_doctor() + + output = " ".join( + [call.args[0] for call in mock_console.print.call_args_list if call.args] + ) + assert "File >100MB detected" in output + assert "Untrack the file or run 'git pulsar ignore '" in output