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
34 changes: 2 additions & 32 deletions src/git_pulsar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from rich.text import Text

from . import __version__, daemon, ops, service, system
from .config import Config
from .config import Config, generate_default_config_template
from .constants import (
APP_NAME,
BACKUP_NAMESPACE,
Expand Down Expand Up @@ -153,38 +153,8 @@ def _ensure_global_config_exists() -> None:
"""Creates the global config file with a comprehensive template if it does not exist."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
template = (
"# Git Pulsar Global Configuration\n"
"# Uncomment settings below to override their defaults.\n\n"
"[core]\n"
"# The namespace used for backup references.\n"
'# backup_branch = "wip/pulsar"\n'
"# The git remote to push backups to.\n"
'# remote_name = "origin"\n\n'
"[daemon]\n"
"# Configuration preset. Options: paranoid, aggressive, balanced, lazy\n"
'# preset = "balanced"\n'
"# Time between local backup commits\n"
'# commit_interval = "10m"\n'
"# Time between pushing to remote\n"
'# push_interval = "1h"\n'
"# Battery percentage floor for commits (pauses backups if battery is lower)\n"
"# min_battery_percent = 10\n"
"# Battery percentage floor for pushing to remotes (pauses pushes if battery is lower)\n"
"# eco_mode_percent = 20\n\n"
"[limits]\n"
"# Max bytes for log files before rotation\n"
'# max_log_size = "5MB"\n'
"# Max bytes for a file before triggering a warning (and ignoring it)\n"
'# large_file_threshold = "100MB"\n\n'
"[files]\n"
"# List of glob patterns to ignore for backups (appended to defaults)\n"
"# ignore = []\n"
"# Whether the daemon is allowed to modify .gitignore automatically\n"
"# manage_gitignore = true\n"
)
with open(CONFIG_FILE, "w") as f:
f.write(template)
f.write(generate_default_config_template())


def open_config() -> None:
Expand Down
143 changes: 126 additions & 17 deletions src/git_pulsar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@

logger = logging.getLogger(APP_NAME)

PRESETS: dict[str, dict[str, int]] = {
"paranoid": {"commit_interval": 300, "push_interval": 300},
"aggressive": {"commit_interval": 600, "push_interval": 600},
"balanced": {"commit_interval": 900, "push_interval": 3600},
"lazy": {"commit_interval": 3600, "push_interval": 14400},
}


def parse_size(value: int | str) -> int:
"""Converts human-readable size strings (e.g., '100MB') to bytes."""
Expand Down Expand Up @@ -48,6 +55,118 @@ def parse_time(value: int | str) -> int:
return int(num * multiplier[unit])


@dataclass(frozen=True)
class ConfigFieldMeta:
"""Metadata describing a configuration setting.

Attributes:
key (str): The configuration field key.
description (str): Human-readable comment explaining the setting.
example_value (str): The example value displayed in configuration templates.
parser (Any): Optional parser function for human-readable values.
"""

key: str
description: str
example_value: str
parser: Any = None


# Single source of truth for configuration metadata and template generation
CONFIG_SECTIONS_METADATA: dict[str, list[ConfigFieldMeta]] = {
"core": [
ConfigFieldMeta(
key="backup_branch",
description="The namespace used for backup references.",
example_value=f'"{BACKUP_NAMESPACE}"',
),
ConfigFieldMeta(
key="remote_name",
description="The git remote to push backups to.",
example_value='"origin"',
),
],
"daemon": [
ConfigFieldMeta(
key="preset",
description=f"Configuration preset. Options: {', '.join(PRESETS.keys())}",
example_value='"balanced"',
),
ConfigFieldMeta(
key="commit_interval",
description="Time between local backup commits",
example_value='"10m"',
parser=parse_time,
),
ConfigFieldMeta(
key="push_interval",
description="Time between pushing to remote",
example_value='"1h"',
parser=parse_time,
),
ConfigFieldMeta(
key="min_battery_percent",
description="Battery percentage floor for commits (pauses backups if battery is lower)",
example_value="10",
),
ConfigFieldMeta(
key="eco_mode_percent",
description="Battery percentage floor for pushing to remotes (pauses pushes if battery is lower)",
example_value="20",
),
],
"limits": [
ConfigFieldMeta(
key="max_log_size",
description="Max bytes for log files before rotation",
example_value='"5MB"',
parser=parse_size,
),
ConfigFieldMeta(
key="large_file_threshold",
description="Max bytes for a file before triggering a warning (and ignoring it)",
example_value='"100MB"',
parser=parse_size,
),
],
"files": [
ConfigFieldMeta(
key="ignore",
description="List of glob patterns to ignore for backups (appended to defaults)",
example_value="[]",
),
ConfigFieldMeta(
key="manage_gitignore",
description="Whether the daemon is allowed to modify .gitignore automatically",
example_value="true",
),
],
}

FIELD_PARSERS: dict[str, Any] = {
meta.key: meta.parser
for section_fields in CONFIG_SECTIONS_METADATA.values()
for meta in section_fields
if meta.parser is not None
}


def generate_default_config_template() -> str:
"""Generates the canonical TOML configuration template with descriptions and default examples."""
lines = [
"# Git Pulsar Global Configuration",
"# Uncomment settings below to override their defaults.",
"",
]
for section, fields in CONFIG_SECTIONS_METADATA.items():
lines.append(f"[{section}]")
for f in fields:
lines.append(f"# {f.description}")
lines.append(f"# {f.key} = {f.example_value}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"


@dataclass
class CoreConfig:
"""Core application settings.
Expand Down Expand Up @@ -108,18 +227,10 @@ class DaemonConfig:

def apply_preset(self) -> None:
"""Overwrites intervals based on the selected preset."""
if self.preset == "paranoid":
self.commit_interval = 300 # 5 mins
self.push_interval = 300 # 5 mins
elif self.preset == "aggressive":
self.commit_interval = 600 # 10 mins
self.push_interval = 600 # 10 mins
elif self.preset == "balanced":
self.commit_interval = 900 # 15 mins
self.push_interval = 3600 # 1 hour
elif self.preset == "lazy":
self.commit_interval = 3600 # 1 hour
self.push_interval = 14400 # 4 hours
if self.preset and self.preset in PRESETS:
preset_values = PRESETS[self.preset]
self.commit_interval = preset_values["commit_interval"]
self.push_interval = preset_values["push_interval"]


@dataclass
Expand Down Expand Up @@ -237,11 +348,9 @@ def _update_dataclass(
continue

try:
# Route specific keys through our parsers
if k in ["max_log_size", "large_file_threshold"]:
filtered_updates[k] = parse_size(v)
elif k in ["commit_interval", "push_interval"]:
filtered_updates[k] = parse_time(v)
parser = FIELD_PARSERS.get(k)
if parser is not None:
filtered_updates[k] = parser(v)
else:
filtered_updates[k] = v
except ValueError as e:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,50 @@ def test_config_load_does_not_mutate_global_cache(
# 3. Verify global cache was not mutated
fresh_global = Config.load()
assert fresh_global.files.ignore == ["*.global"]


def test_generate_default_config_template() -> None:
"""Verifies that generate_default_config_template produces a comprehensive and valid TOML template."""
import tomllib

from git_pulsar.config import (
CONFIG_SECTIONS_METADATA,
generate_default_config_template,
)

template = generate_default_config_template()

# Check all sections and keys are present
for section, fields in CONFIG_SECTIONS_METADATA.items():
assert f"[{section}]" in template
for f in fields:
assert f.key in template
assert f.description in template

# When uncommented, the template should be valid TOML
uncommented_lines = []
for line in template.splitlines():
if (line.startswith("# [") and line.endswith("]")) or (
line.startswith("# ") and "=" in line
):
uncommented_lines.append(line[2:])
elif not line.startswith("#"):
uncommented_lines.append(line)

uncommented_toml = "\n".join(uncommented_lines)
parsed = tomllib.loads(uncommented_toml)
for section in CONFIG_SECTIONS_METADATA:
assert section in parsed


def test_config_presets_unknown() -> None:
"""Verifies that an unknown preset does not alter intervals and is handled safely."""
conf = Config()
original_commit = conf.daemon.commit_interval
original_push = conf.daemon.push_interval

conf.daemon.preset = "nonexistent_preset"
conf.daemon.apply_preset()

assert conf.daemon.commit_interval == original_commit
assert conf.daemon.push_interval == original_push