From c6d8300837f1760351f7dd1f0ca5e89d13e1f4db Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:05:47 -0800 Subject: [PATCH 1/8] feat(cli): introduce interactive resolution queue for doctor Establishes a two-stage pipeline for `git pulsar doctor` by separating the diagnostic scanning phase from an interactive resolution phase. Introduces the `DoctorAction` dataclass to encapsulate actionable fixes and adds the execution loop at the end of the doctor routine. --- src/git_pulsar/cli.py | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 7836fe6..6dd45bf 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. @@ -481,9 +500,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() @@ -618,6 +640,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. From 0d0f7c3b8db1e643cddfcce48b58c90348e6f54b Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:09:25 -0800 Subject: [PATCH 2/8] feat(cli): migrate registry and daemon checks to interactive fixes Ports elementary system-level checks to the interactive resolution queue. Modifies registry cleanup to explicitly prompt before removing ghost directories. Adds prompts to install the background service if stopped, and to enable systemd user lingering on Linux if it is currently disabled. --- src/git_pulsar/cli.py | 64 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 6dd45bf..1f97860 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -513,18 +513,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]") @@ -537,9 +552,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. From e330d02c4ba5de0f0bacd32830a8d9a43bd7c370 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:12:32 -0800 Subject: [PATCH 3/8] feat(cli): add repository state and index lock auto-fixes Implements interactive resolutions for repository-specific issues during the diagnostic run. Adds auto-fixes for detected remote session drift (triggering a sync), resuming paused repositories, and a new check to identify and safely remove orphaned `.git/index.lock` files older than 2 hours. --- src/git_pulsar/cli.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 1f97860..0a7ac81 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -619,6 +619,25 @@ def install_daemon() -> bool: 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]" @@ -638,6 +657,60 @@ def install_daemon() -> bool: 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. Standard health check if problem := _check_repo_health(p, repo_config): issues.append(f"{p.name}: {problem}") From d0a34726fb6fe0eb152a6f3fa0d443b3f6325942 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:18:10 -0800 Subject: [PATCH 4/8] refactor(cli): clarify manual intervention outputs in doctor Updates diagnostic reporting for Tier 2 issues that cannot be safely auto-fixed. Enhances warnings for SSH connectivity failures, large file blockers, and strict git hooks by providing explicit, copy-pasteable terminal commands and actionable resolution steps directly in the output. --- src/git_pulsar/cli.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 0a7ac81..0bbd1ed 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -488,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}") @@ -604,11 +605,15 @@ def install_daemon() -> bool: 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() @@ -710,7 +715,17 @@ def remove_lock(path_to_lock: Path = lock_file) -> bool: except OSError: pass # Lock file vanished during read (race resolved) - # 1c. Standard health check + # 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}") @@ -725,8 +740,7 @@ def remove_lock(path_to_lock: Path = lock_file) -> bool: 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( From 5dfb643db7e6ae7609df4e6a81b91e55d5938a69 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:29:12 -0800 Subject: [PATCH 5/8] test(cli): add coverage for interactive doctor resolution queue Introduces test coverage for the two-stage interactive doctor pipeline. Validates that the execution loop respects user prompts (executing confirmed closures and bypassing declined ones). Adds specific coverage for Tier 1 auto-fixes (stale index lock removal, ghost registry cleanup, session drift syncing, and unpausing repositories) and verifies the exact stdout formatting for Tier 2 guided manual interventions (large files and strict git hooks). --- tests/test_cli.py | 281 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) 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 From 3e8933557038eceb9e18a81741f05f7a8fbeb69b Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:29:21 -0800 Subject: [PATCH 6/8] docs(tests): update testing strategy for interactive doctor queue Updates the tests/README.md to document the verification strategy for the new two-stage interactive doctor pipeline. Adds coverage descriptions for the Interactive Resolution Queue, auto-fix closures, and stdout formatting for guided manual interventions within `test_cli.py`. --- tests/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. --- From 0ae21e8c5d23b2255d2462e38c08878424a5ac0f Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:30:12 -0800 Subject: [PATCH 7/8] docs(src): detail interactive doctor queue in architecture map Updates the `src/README.md` module map to document the two-stage interactive diagnostic pipeline within `cli.py`. Outlines the separation between the scanning phase and the interactive resolution queue, and introduces a new invariant guaranteeing explicit user confirmation before state-altering auto-fixes. --- src/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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). From a679178cf170266c95145f4c3eec57a4b5aa138d Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Fri, 20 Feb 2026 20:31:10 -0800 Subject: [PATCH 8/8] docs(readme): mark active doctor as complete and update feature list Updates the root README to reflect the completion of the "Active Doctor" roadmap item. Modifies the feature highlight and command reference for `git pulsar doctor` to note its new interactive auto-fix capabilities, maintaining a clean diff for surrounding context. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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)