Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand All @@ -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.
218 changes: 198 additions & 20 deletions src/git_pulsar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .config import CONFIG_FILE, Config
from .constants import (
APP_NAME,
BACKUP_NAMESPACE,
DEFAULT_IGNORES,
LOG_FILE,
PID_FILE,
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'."
Expand All @@ -413,36 +558,42 @@ 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:
paths = [Path(line.strip()) for line in f if line.strip()]

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}")
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading