diff --git a/src/git_pulsar/daemon.py b/src/git_pulsar/daemon.py index 66f9f82..6a37ee1 100644 --- a/src/git_pulsar/daemon.py +++ b/src/git_pulsar/daemon.py @@ -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() @@ -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. @@ -222,22 +225,20 @@ 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. @@ -245,18 +246,17 @@ def _attempt_push( 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.") @@ -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" ): @@ -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}") @@ -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: @@ -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 @@ -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) @@ -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") diff --git a/src/git_pulsar/git_wrapper.py b/src/git_pulsar/git_wrapper.py index f94098c..191a3e1 100644 --- a/src/git_pulsar/git_wrapper.py +++ b/src/git_pulsar/git_wrapper.py @@ -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) @@ -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( diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index 9e8e31c..3fa1e2b 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -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: diff --git a/src/git_pulsar/service.py b/src/git_pulsar/service.py index 3c5e7c9..b0155f9 100644 --- a/src/git_pulsar/service.py +++ b/src/git_pulsar/service.py @@ -6,6 +6,7 @@ from rich.console import Console from .constants import APP_LABEL, HOMEBREW_LABEL, LOG_FILE +from .types import ServiceUnitConfig console = Console() @@ -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" @@ -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] @@ -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: diff --git a/src/git_pulsar/types.py b/src/git_pulsar/types.py index 217839d..ea0da77 100644 --- a/src/git_pulsar/types.py +++ b/src/git_pulsar/types.py @@ -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) --- @@ -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 diff --git a/tests/test_daemon.py b/tests/test_daemon.py index f87b32a..a95ad59 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -8,6 +8,7 @@ from git_pulsar import daemon from git_pulsar.config import Config from git_pulsar.constants import BACKUP_NAMESPACE +from git_pulsar.types import BackupOptions, CommitTreeParams, DriftState @pytest.fixture @@ -183,7 +184,9 @@ def test_run_backup_drift_detection_triggers_notification( mock_notify.assert_called_once_with("Pulsar Drift Detected", warning_msg) # Assert state was updated so we don't spam the user again for timestamp 5000 - mock_set_state.assert_called_once_with(tmp_path.resolve(), current_time, 5000) + mock_set_state.assert_called_once_with( + tmp_path.resolve(), DriftState(current_time, 5000) + ) def test_main_signal_alarm_reset_on_exception( @@ -232,8 +235,11 @@ def test_run_backup_deduplicates_identical_parents( # Assert commit_tree was called with parents=["same_sha"] (no duplicate) repo.commit_tree.assert_called_once() - _, kwargs = repo.commit_tree.call_args - assert kwargs["parents"] == ["same_sha"] + args, kwargs = repo.commit_tree.call_args + if args and isinstance(args[0], CommitTreeParams): + assert args[0].parents == ["same_sha"] + else: + assert kwargs.get("parents") == ["same_sha"] def test_run_backup_push_uses_oid_comparison( @@ -311,15 +317,24 @@ def test_should_skip_paused_repo(tmp_path: Path) -> None: (git_dir / "pulsar_paused").touch() conf = Config() - assert daemon._should_skip(tmp_path, conf, interactive=False) == "Paused by user" - assert daemon._should_skip(tmp_path, conf, interactive=True) == "Paused by user" + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=False)) + == "Paused by user" + ) + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=True)) + == "Paused by user" + ) def test_should_skip_missing_path(tmp_path: Path) -> None: """Verifies that _should_skip returns 'Path missing' when the repo directory does not exist.""" missing = tmp_path / "nonexistent_repo" conf = Config() - assert daemon._should_skip(missing, conf, interactive=False) == "Path missing" + assert ( + daemon._should_skip(missing, BackupOptions(config=conf, interactive=False)) + == "Path missing" + ) def test_should_skip_battery_critical(tmp_path: Path, mocker: MagicMock) -> None: @@ -332,9 +347,15 @@ def test_should_skip_battery_critical(tmp_path: Path, mocker: MagicMock) -> None mocker.patch("git_pulsar.daemon.SYSTEM.get_battery", return_value=(10, False)) # Background mode should skip - assert daemon._should_skip(tmp_path, conf, interactive=False) == "Battery critical" + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=False)) + == "Battery critical" + ) # Interactive mode should proceed regardless of battery - assert daemon._should_skip(tmp_path, conf, interactive=True) is None + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=True)) + is None + ) def test_should_skip_system_under_load(tmp_path: Path, mocker: MagicMock) -> None: @@ -343,8 +364,14 @@ def test_should_skip_system_under_load(tmp_path: Path, mocker: MagicMock) -> Non conf = Config() mocker.patch("git_pulsar.daemon.SYSTEM.is_under_load", return_value=True) - assert daemon._should_skip(tmp_path, conf, interactive=False) == "System under load" - assert daemon._should_skip(tmp_path, conf, interactive=True) is None + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=False)) + == "System under load" + ) + assert ( + daemon._should_skip(tmp_path, BackupOptions(config=conf, interactive=True)) + is None + ) def test_attempt_push_skips_in_eco_mode(mocker: MagicMock) -> None: @@ -355,7 +382,7 @@ def test_attempt_push_skips_in_eco_mode(mocker: MagicMock) -> None: conf.daemon.eco_mode_percent = 25 mocker.patch("git_pulsar.daemon.SYSTEM.get_battery", return_value=(20, False)) - daemon._attempt_push(repo, "ref:ref", conf, interactive=False) + daemon._attempt_push(repo, "ref:ref", BackupOptions(config=conf, interactive=False)) repo._run.assert_not_called() @@ -370,7 +397,7 @@ def test_attempt_push_skips_when_offline(mocker: MagicMock) -> None: mocker.patch("git_pulsar.daemon.get_remote_host", return_value="github.com") mocker.patch("git_pulsar.daemon.is_remote_reachable", return_value=False) - daemon._attempt_push(repo, "ref:ref", conf, interactive=False) + daemon._attempt_push(repo, "ref:ref", BackupOptions(config=conf, interactive=False)) repo._run.assert_not_called() @@ -385,7 +412,7 @@ def test_attempt_push_executes_successfully(mocker: MagicMock) -> None: mocker.patch("git_pulsar.daemon.is_remote_reachable", return_value=True) # Background mode - daemon._attempt_push(repo, "ref:ref", conf, interactive=False) + daemon._attempt_push(repo, "ref:ref", BackupOptions(config=conf, interactive=False)) repo._run.assert_called_once_with( ["push", "origin", "ref:ref"], capture=True, @@ -396,7 +423,7 @@ def test_attempt_push_executes_successfully(mocker: MagicMock) -> None: # Interactive mode repo.reset_mock() mocker.patch("git_pulsar.daemon.console.status") - daemon._attempt_push(repo, "ref:ref", conf, interactive=True) + daemon._attempt_push(repo, "ref:ref", BackupOptions(config=conf, interactive=True)) repo._run.assert_called_once() diff --git a/tests/test_git_wrapper.py b/tests/test_git_wrapper.py index f4aa17e..8763da2 100644 --- a/tests/test_git_wrapper.py +++ b/tests/test_git_wrapper.py @@ -5,6 +5,7 @@ import pytest from git_pulsar.git_wrapper import GitRepo, get_git_dir +from git_pulsar.types import CommitTreeParams def test_list_refs_logs_error_on_failure( @@ -296,3 +297,34 @@ def test_get_commit_timestamp(tmp_path: Path, mocker: MagicMock) -> None: # Error handling / missing ref returns None mock_run.side_effect = RuntimeError("unknown revision") assert repo.get_commit_timestamp("nonexistent_ref") is None + + +def test_commit_tree_with_params_object(tmp_path: Path, mocker: MagicMock) -> None: + """Verifies that commit_tree correctly unpacks a CommitTreeParams object.""" + (tmp_path / ".git").mkdir() + repo = GitRepo(tmp_path) + mock_run = mocker.patch.object(repo, "_run", return_value="new_commit_sha_1234") + + params = CommitTreeParams( + tree="tree_sha_abc", + parents=["parent_1", "parent_2"], + message="Structured commit message", + env={"GIT_INDEX_FILE": "/tmp/custom_index"}, + ) + + result = repo.commit_tree(params) + + assert result == "new_commit_sha_1234" + mock_run.assert_called_once_with( + [ + "commit-tree", + "tree_sha_abc", + "-m", + "Structured commit message", + "-p", + "parent_1", + "-p", + "parent_2", + ], + env={"GIT_INDEX_FILE": "/tmp/custom_index"}, + ) diff --git a/tests/test_ops.py b/tests/test_ops.py index f92fd15..7827a4e 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -9,6 +9,7 @@ from git_pulsar import ops from git_pulsar.config import Config from git_pulsar.constants import BACKUP_NAMESPACE +from git_pulsar.types import DriftState # Restore / Sync Tests @@ -371,7 +372,7 @@ def test_set_drift_state_atomic(tmp_path: Path) -> None: git_dir.mkdir() state_file = git_dir / "pulsar_drift_state" - ops.set_drift_state(tmp_path, 999.9, 200) + ops.set_drift_state(tmp_path, DriftState(999.9, 200)) assert state_file.exists() data = json.loads(state_file.read_text()) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..3845f2d --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,75 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from git_pulsar import service +from git_pulsar.constants import APP_LABEL +from git_pulsar.types import ServiceUnitConfig + + +def test_install_linux_creates_service_and_timer_files( + tmp_path: Path, mocker: MagicMock +) -> None: + """Verifies that install_linux writes systemd service and timer files and enables timer.""" + mock_run = mocker.patch("subprocess.run") + mocker.patch("git_pulsar.service.console") + + unit_path = tmp_path / "user" / f"{APP_LABEL}.service" + config = ServiceUnitConfig( + unit_path=unit_path, + executable="/usr/local/bin/git-pulsar-daemon", + interval=600, + log_path=tmp_path / "log" / "pulsar.log", + ) + + service.install_linux(config) + + service_file = tmp_path / "user" / f"{APP_LABEL}.service" + timer_file = tmp_path / "user" / f"{APP_LABEL}.timer" + + assert service_file.exists() + assert "/usr/local/bin/git-pulsar-daemon" in service_file.read_text() + assert timer_file.exists() + assert "OnUnitActiveSec=600s" in timer_file.read_text() + + mock_run.assert_any_call(["systemctl", "--user", "daemon-reload"], check=True) + mock_run.assert_any_call( + ["systemctl", "--user", "enable", "--now", f"{APP_LABEL}.timer"], check=True + ) + + +def test_is_service_enabled_darwin(mocker: MagicMock) -> None: + """Verifies is_service_enabled queries launchctl on macOS.""" + mocker.patch("sys.platform", "darwin") + mocker.patch( + "subprocess.run", + return_value=MagicMock(stdout="homebrew.mxcl.git-pulsar\n"), + ) + assert service.is_service_enabled() is True + + +def test_is_service_enabled_linux(mocker: MagicMock) -> None: + """Verifies is_service_enabled queries systemctl on Linux.""" + mocker.patch("sys.platform", "linux") + mocker.patch( + "subprocess.run", + return_value=MagicMock(stdout="active\n"), + ) + assert service.is_service_enabled() is True + + +def test_get_paths_darwin_raises(mocker: MagicMock) -> None: + """Verifies get_paths raises NotImplementedError on macOS.""" + mocker.patch("sys.platform", "darwin") + with pytest.raises(NotImplementedError): + service.get_paths() + + +def test_get_paths_linux(mocker: MagicMock) -> None: + """Verifies get_paths returns systemd and log paths on Linux.""" + mocker.patch("sys.platform", "linux") + service_path, log_path = service.get_paths() + assert service_path.name == f"{APP_LABEL}.service" + assert "systemd" in str(service_path) + assert log_path.name == "daemon.log" diff --git a/tests/test_types.py b/tests/test_types.py index afce06d..f820e89 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,11 +1,14 @@ -"""Tests for domain types, enums, newtypes, and structured data classes.""" +from pathlib import Path +from git_pulsar.config import Config from git_pulsar.types import ( + BackupOptions, BackupRefInfo, BatteryStatus, BranchName, ByteSize, CommitSHA, + CommitTreeParams, ConfigSection, DaemonStatus, DiffStat, @@ -19,6 +22,7 @@ RemoteDriftResult, RepoStatus, Seconds, + ServiceUnitConfig, SkipReason, TreeSHA, ) @@ -154,3 +158,49 @@ def test_backup_ref_info_dataclass() -> None: assert info.slug == "mac--123" assert info.machine_name == "mac" assert info.branch == "main" + + +def test_service_unit_config_dataclass() -> None: + """Verifies ServiceUnitConfig dataclass instantiation, default values, and field access.""" + cfg = ServiceUnitConfig( + unit_path=Path("/tmp/pulsar.service"), + executable="/usr/local/bin/git-pulsar-daemon", + ) + assert cfg.unit_path == Path("/tmp/pulsar.service") + assert cfg.executable == "/usr/local/bin/git-pulsar-daemon" + assert cfg.interval == 900 + assert cfg.log_path is None + + cfg_custom = ServiceUnitConfig( + unit_path=Path("/tmp/custom.service"), + executable="/bin/daemon", + interval=Seconds(300), + log_path=Path("/var/log/pulsar.log"), + ) + assert cfg_custom.interval == 300 + assert cfg_custom.log_path == Path("/var/log/pulsar.log") + + +def test_backup_options_dataclass() -> None: + """Verifies BackupOptions dataclass instantiation and default values.""" + conf = Config() + opts = BackupOptions(config=conf) + assert opts.config is conf + assert opts.interactive is False + + interactive_opts = BackupOptions(config=conf, interactive=True) + assert interactive_opts.interactive is True + + +def test_commit_tree_params_dataclass() -> None: + """Verifies CommitTreeParams dataclass instantiation and fields.""" + params = CommitTreeParams( + tree=TreeSHA(GitOID("a" * 40)), + parents=[CommitSHA(GitOID("b" * 40))], + message="Test shadow commit", + env={"GIT_INDEX_FILE": "/tmp/idx"}, + ) + assert params.tree == "a" * 40 + assert params.parents == ["b" * 40] + assert params.message == "Test shadow commit" + assert params.env == {"GIT_INDEX_FILE": "/tmp/idx"}