Skip to content
58 changes: 33 additions & 25 deletions src/git_pulsar/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@
)
from .git_wrapper import GitRepo, get_git_dir
from .system import get_system
from .types import GitRef, SkipReason
from .types import (
BackupOptions,
CommitTreeParams,
DriftState,
GitRef,
SkipReason,
)

SYSTEM = get_system()

Expand Down Expand Up @@ -203,15 +209,12 @@ def prune_registry(original_path_str: str) -> None:
SYSTEM.notify("Backup Stopped", f"Removed missing repo: {repo_name}")


def _should_skip(
repo_path: Path, config: Config, interactive: bool
) -> SkipReason | None:
def _should_skip(repo_path: Path, options: BackupOptions) -> SkipReason | None:
"""Determines if the backup for a given repository should be skipped.

Args:
repo_path (Path): The repository path.
config (Config): The configuration instance for this repository.
interactive (bool): Whether the session is interactive (CLI) or background.
options (BackupOptions): The execution options and configuration.

Returns:
SkipReason | None: The reason for skipping, or None if backup should proceed.
Expand All @@ -222,41 +225,38 @@ def _should_skip(
if ops.is_repo_paused(repo_path):
return SkipReason.PAUSED

if not interactive:
if not options.interactive:
if SYSTEM.is_under_load():
return SkipReason.SYSTEM_UNDER_LOAD

# Check battery levels (don't drain battery on background tasks).
pct, plugged = SYSTEM.get_battery()
# Uses config value instead of hardcoded '10'
if not plugged and pct < config.daemon.min_battery_percent:
if not plugged and pct < options.config.daemon.min_battery_percent:
return SkipReason.BATTERY_CRITICAL

return None


def _attempt_push(
repo: GitRepo, refspec: str, config: Config, interactive: bool
) -> None:
def _attempt_push(repo: GitRepo, refspec: str, options: BackupOptions) -> None:
"""Attempts to push the backup reference to the remote.

Respects eco-mode settings and network availability.

Args:
repo (GitRepo): The repository instance.
refspec (str): The refspec to push (e.g., 'ref:ref').
config (Config): The configuration instance for this repository.
interactive (bool): Whether to output status to the console.
options (BackupOptions): The execution options and configuration.
"""
# 1. Eco Mode Check.
percent, plugged = SYSTEM.get_battery()
# Uses config value instead of hardcoded '20'
if not plugged and percent < config.daemon.eco_mode_percent:
if not plugged and percent < options.config.daemon.eco_mode_percent:
logger.info(f"ECO MODE {repo.path.name}: Committed. Push skipped.")
return

# 2. Network Connectivity Check.
remote_name = config.core.remote_name
remote_name = options.config.core.remote_name
host = get_remote_host(repo.path, remote_name)
if host and not is_remote_reachable(host):
logger.info(f"OFFLINE {repo.path.name}: Committed. Push skipped.")
Expand All @@ -268,7 +268,7 @@ def _attempt_push(
env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"
cmd = ["push", remote_name, refspec]

if interactive:
if options.interactive:
with console.status(
f"[bold blue]Pushing {repo.path.name}...[/bold blue]", spinner="dots"
):
Expand All @@ -281,7 +281,7 @@ def _attempt_push(
logger.info(f"SUCCESS {repo.path.name}: Pushed.")

except Exception as e:
if interactive:
if options.interactive:
console.print(f"[bold red]PUSH ERROR {repo.path.name}:[/bold red] {e}")
else:
logger.error(f"PUSH ERROR {repo.path.name}: {e}")
Expand All @@ -306,9 +306,10 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:

# Load context-aware config (Global + Local)
config = Config.load(repo_path)
options = BackupOptions(config=config, interactive=interactive)

# Pass config to _should_skip
if reason := _should_skip(repo_path, config, interactive):
# Pass options to _should_skip
if reason := _should_skip(repo_path, options):
if reason == SkipReason.PATH_MISSING:
prune_registry(original_path_str)
elif reason == SkipReason.SYSTEM_UNDER_LOAD:
Expand Down Expand Up @@ -352,13 +353,19 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
)
# Trigger the OS interrupt
SYSTEM.notify("Pulsar Drift Detected", warning)
ops.set_drift_state(repo_path, current_time, newest_ts)
ops.set_drift_state(
repo_path, DriftState(current_time, newest_ts)
)
else:
# State is clean or already warned; update the check timestamp
ops.set_drift_state(repo_path, current_time, warned_ts)
ops.set_drift_state(
repo_path, DriftState(current_time, warned_ts)
)
else:
# Offline; update check timestamp to avoid spamming TCP handshakes
ops.set_drift_state(repo_path, current_time, warned_ts)
ops.set_drift_state(
repo_path, DriftState(current_time, warned_ts)
)

# --- COMMIT PHASE ---
# Define Refs
Expand Down Expand Up @@ -398,12 +405,13 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# Use wrapper method
commit_oid = repo.commit_tree(
commit_params = CommitTreeParams(
tree=tree_oid,
parents=parents,
message=f"Shadow backup {timestamp}",
env=env,
)
commit_oid = repo.commit_tree(commit_params)

# Use wrapper method
repo.update_ref(local_backup_ref, commit_oid, parent_backup)
Expand All @@ -424,8 +432,8 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
time_since_push >= config.daemon.push_interval or interactive
):
refspec = f"{local_backup_ref}:{local_backup_ref}"
# Pass config to _attempt_push
_attempt_push(repo, refspec, config, interactive)
# Pass options to _attempt_push
_attempt_push(repo, refspec, options)

except Exception:
logger.exception(f"CRITICAL {repo_path.name}: Backup iteration failed")
Expand Down
46 changes: 33 additions & 13 deletions src/git_pulsar/git_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
from pathlib import Path

from .constants import APP_NAME
from .types import BranchName, CommitSHA, DiffStat, GitOID, GitRef, TreeSHA
from .types import (
BranchName,
CommitSHA,
CommitTreeParams,
DiffStat,
GitOID,
GitRef,
TreeSHA,
)

logger = logging.getLogger(APP_NAME)

Expand Down Expand Up @@ -268,30 +276,42 @@ def write_tree(self, env: dict[str, str] | None = None) -> TreeSHA:

def commit_tree(
self,
tree: TreeSHA | str,
parents: Sequence[GitOID | str],
message: str,
tree: TreeSHA | GitOID | str | CommitTreeParams,
parents: Sequence[GitOID | CommitSHA | str] | None = None,
message: str = "",
env: dict[str, str] | None = None,
) -> CommitSHA:
"""Creates a commit object from a tree object.

Accepts either a `CommitTreeParams` parameter object or individual parameters.

Args:
tree (TreeSHA | str): The tree SHA-1 to commit.
parents (Sequence[GitOID | str]): A sequence of parent commit SHA-1s.
message (str): The commit message.
env (Optional[dict], optional): Environment variables to
pass to the subprocess.
tree (TreeSHA | GitOID | str | CommitTreeParams): The tree SHA-1 or a CommitTreeParams object.
parents (Sequence[GitOID | CommitSHA | str] | None, optional): A sequence of parent commit SHA-1s.
message (str, optional): The commit message.
env (Optional[dict], optional): Environment variables to pass to the subprocess.

Returns:
CommitSHA: The SHA-1 hash of the new commit.
"""
cmd = ["commit-tree", str(tree), "-m", message]
for p in parents:
if isinstance(tree, CommitTreeParams):
actual_tree = tree.tree
actual_parents = tree.parents
actual_message = tree.message
actual_env = tree.env
else:
actual_tree = tree
actual_parents = parents or []
actual_message = message
actual_env = env

cmd = ["commit-tree", str(actual_tree), "-m", actual_message]
for p in actual_parents:
cmd.extend(["-p", str(p)])
try:
return CommitSHA(GitOID(self._run(cmd, env=env)))
return CommitSHA(GitOID(self._run(cmd, env=actual_env)))
except Exception as e:
logger.warning(f"Failed to commit tree {tree}: {e}")
logger.warning(f"Failed to commit tree {actual_tree}: {e}")
raise

def update_ref(
Expand Down
11 changes: 4 additions & 7 deletions src/git_pulsar/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,22 +227,19 @@ def get_drift_state(repo_path: Path) -> DriftState:
return DriftState(0.0, 0)


def set_drift_state(
repo_path: Path, last_check_ts: float, warned_remote_ts: int
) -> None:
def set_drift_state(repo_path: Path, state: DriftState) -> None:
"""Persists the drift detection state to disk atomically.

Args:
repo_path (Path): The path to the repository.
last_check_ts (float): The Unix timestamp of the current check.
warned_remote_ts (int): The Unix timestamp of the remote session warned about.
state (DriftState): The drift state containing last check and warned timestamps.
"""
state_file = get_git_dir(repo_path) / "pulsar_drift_state"
tmp_file = state_file.with_suffix(".tmp")

data = {
"last_check_ts": last_check_ts,
"warned_remote_ts": warned_remote_ts,
"last_check_ts": state.last_check_ts,
"warned_remote_ts": state.warned_remote_ts,
}

try:
Expand Down
26 changes: 14 additions & 12 deletions src/git_pulsar/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rich.console import Console

from .constants import APP_LABEL, HOMEBREW_LABEL, LOG_FILE
from .types import ServiceUnitConfig

console = Console()

Expand Down Expand Up @@ -67,21 +68,16 @@ def get_paths() -> tuple[Path, Path]:
raise NotImplementedError("Service installation is managed by Homebrew on macOS.")


def install_linux(
unit_path: Path, log_path: Path, executable: str, interval: int
) -> None:
def install_linux(config: ServiceUnitConfig) -> None:
"""Configures and enables a systemd user timer for Linux.

Creates the .service and .timer unit files in the user's systemd configuration
directory, reloads the daemon, and enables the timer.

Args:
unit_path (Path): The target path for the .service file.
log_path (Path): The path to the log file.
executable (str): The path to the daemon executable.
interval (int): The backup interval in seconds.
config (ServiceUnitConfig): Configuration parameters for the Linux service unit.
"""
base_dir = unit_path.parent
base_dir = config.unit_path.parent
base_dir.mkdir(parents=True, exist_ok=True)

service_file = base_dir / f"{APP_LABEL}.service"
Expand All @@ -91,14 +87,14 @@ def install_linux(
Description=Git Pulsar Backup Daemon

[Service]
ExecStart={executable}
ExecStart={config.executable}
"""
timer_content = f"""[Unit]
Description=Run Git Pulsar every {interval} seconds
Description=Run Git Pulsar every {config.interval} seconds

[Timer]
OnBootSec=5min
OnUnitActiveSec={interval}s
OnUnitActiveSec={config.interval}s
Unit={APP_LABEL}.service

[Install]
Expand Down Expand Up @@ -144,7 +140,13 @@ def install(interval: int = 900) -> None:

console.print(f"Installing background service (interval: {interval}s)...")
if sys.platform.startswith("linux"):
install_linux(path, log, exe, interval)
unit_config = ServiceUnitConfig(
unit_path=path,
executable=exe,
interval=interval,
log_path=log,
)
install_linux(unit_config)


def uninstall() -> None:
Expand Down
59 changes: 58 additions & 1 deletion src/git_pulsar/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
dataclasses/NamedTuples) to replace raw primitive usage across the codebase.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from enum import StrEnum
from typing import NamedTuple, NewType
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, NewType

if TYPE_CHECKING:
from .config import Config

# --- Enums (Fixed Sets of Values) ---

Expand Down Expand Up @@ -139,3 +146,53 @@ class BackupRefInfo:
slug: MachineSlug
machine_name: MachineName
branch: BranchName


# --- Parameter Objects (Introduce Parameter Object Pattern) ---


@dataclass(frozen=True)
class ServiceUnitConfig:
"""Configuration parameters for installing a Linux systemd service unit.

Attributes:
unit_path (Path): Target path for the .service unit file.
executable (str): Path to the daemon executable.
interval (Seconds | int): Interval between backup runs in seconds. Defaults to 900.
log_path (Path | None): Path to the log file. Defaults to None.
"""

unit_path: Path
executable: str
interval: Seconds | int = Seconds(900)
log_path: Path | None = None


@dataclass(frozen=True)
class BackupOptions:
"""Execution options and configuration for a backup iteration.

Attributes:
config (Config): Merged configuration object for this repository.
interactive (bool): Whether running in interactive CLI mode vs. background daemon.
"""

config: Config
interactive: bool = False


@dataclass(frozen=True)
class CommitTreeParams:
"""Parameters for creating a commit object directly from a tree.

Attributes:
tree (TreeSHA | GitOID | str): SHA-1 hash of the tree object to commit.
parents (Sequence[GitOID | CommitSHA | str]): Parent commit SHA-1 hashes.
message (str): Commit message string.
env (dict[str, str] | None): Optional environment variables (e.g. custom GIT_INDEX_FILE).
"""

tree: TreeSHA | GitOID | str
parents: Sequence[GitOID | CommitSHA | str]
message: str
env: dict[str, str] | None = None
Loading