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
179 changes: 157 additions & 22 deletions src/git_pulsar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ def setup_repo(registry_path: Path = REGISTRY_FILE) -> None:
Defaults to REGISTRY_FILE.
"""
cwd = Path.cwd()
config = Config.load(cwd)

# Ensure the directory is a git repository.
if not (cwd / ".git").exists():
Expand All @@ -894,33 +895,38 @@ def setup_repo(registry_path: Path = REGISTRY_FILE) -> None:
repo = GitRepo(cwd)

# Trigger Identity Configuration (with Sync)
# We pass the repo so it can check 'origin' for collisions.
system.configure_identity(repo)

# Ensure a .gitignore file exists and contains default patterns.
gitignore = cwd / ".gitignore"
if config.files.manage_gitignore:
gitignore = cwd / ".gitignore"

if not gitignore.exists():
console.print("[dim]Creating basic .gitignore...[/dim]")
with open(gitignore, "w") as f:
f.write("\n".join(DEFAULT_IGNORES) + "\n")
if not gitignore.exists():
console.print("[dim]Creating basic .gitignore...[/dim]")
with open(gitignore, "w") as f:
f.write("\n".join(DEFAULT_IGNORES) + "\n")
else:
console.print(
"Existing .gitignore found. Checking for missing defaults...",
style="dim",
)
with open(gitignore) as f:
existing_content = f.read()

missing_defaults = [d for d in DEFAULT_IGNORES if d not in existing_content]

if missing_defaults:
console.print(
f"Appending {len(missing_defaults)} missing ignores...", style="dim"
)
with open(gitignore, "a") as f:
f.write("\n" + "\n".join(missing_defaults) + "\n")
else:
console.print("All defaults present.", style="dim")
else:
console.print(
"Existing .gitignore found. Checking for missing defaults...", style="dim"
"Skipping .gitignore management (manage_gitignore=false).", style="dim"
)
with open(gitignore) as f:
existing_content = f.read()

missing_defaults = [d for d in DEFAULT_IGNORES if d not in existing_content]

if missing_defaults:
console.print(
f"Appending {len(missing_defaults)} missing ignores...", style="dim"
)
with open(gitignore, "a") as f:
f.write("\n" + "\n".join(missing_defaults) + "\n")
else:
console.print("All defaults present.", style="dim")

# Register the repository path.
console.print("Registering path...", style="dim")
Expand Down Expand Up @@ -1003,6 +1009,123 @@ def _format_action(self, action: argparse.Action) -> str:
return super()._format_action(action)


def show_config_reference() -> None:
"""Displays a formatted table of all available configuration options."""
from rich.table import Table

table = Table(title="Git Pulsar Configuration Schema", show_lines=True)
table.add_column("Section", style="cyan", justify="right")
table.add_column("Key", style="green")
table.add_column("Type", style="dim")
table.add_column("Default", style="yellow")
table.add_column("Description")

# Core Settings
table.add_row(
"core",
"backup_branch",
"str",
'"wip/pulsar"',
"The Git namespace used for shadow commits.",
)
table.add_row(
"", "remote_name", "str", '"origin"', "The remote target for pushing backups."
)

# Daemon Settings
table.add_row(
"daemon",
"preset",
"str",
"None",
"Interval preset: 'paranoid', 'aggressive', 'balanced', or 'lazy'.",
)
table.add_row(
"",
"commit_interval",
"int | str",
'"10m"',
"Time between local state captures (e.g., '10m', '1hr', 600).",
)
table.add_row(
"",
"push_interval",
"int | str",
'"1hr"',
"Time between remote pushes (e.g., '1hr', '30m', 3600).",
)
table.add_row(
"",
"min_battery_percent",
"int",
"10",
"Stops all daemon activity if battery drops below this.",
)
table.add_row(
"",
"eco_mode_percent",
"int",
"20",
"Suspends remote pushes if battery drops below this.",
)

# Files Settings
table.add_row(
"files", "ignore", "list", "[]", "Extra glob patterns to append to .gitignore."
)
table.add_row(
"",
"manage_gitignore",
"bool",
"true",
"Allow daemon to automatically add rules to .gitignore.",
)

# Limits Settings
table.add_row(
"limits",
"max_log_size",
"int | str",
'"5mb"',
"Max size for log files before rotation (e.g., '5mb', '1gb').",
)
table.add_row(
"",
"large_file_threshold",
"int | str",
'"100mb"',
"Max file size before aborting a backup (e.g., '100mb', '2gb').",
)

# Env Settings
table.add_row(
"env",
"python_version",
"str",
'"3.12"',
"Target Python version for the uv virtual environment.",
)
table.add_row(
"", "venv_dir", "str", '".venv"', "Directory name for the virtual environment."
)
table.add_row(
"",
"generate_vscode_settings",
"bool",
"true",
"Generate workspace settings for VS Code.",
)
table.add_row(
"",
"generate_direnv",
"bool",
"true",
"Generate .envrc for automatic environment activation.",
)

console.print(table)


def main() -> None:
"""Main entry point for the Git Pulsar CLI."""
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -1069,7 +1192,16 @@ def main() -> None:
subparsers.add_parser("remove", help="Stop tracking current repo")
subparsers.add_parser("sync", help="Sync with latest session")
subparsers.add_parser("doctor", help="Clean registry and check health")
subparsers.add_parser("config", help="Open global config file")

config_parser = subparsers.add_parser(
"config", help="Open global config file or view options"
)
config_parser.add_argument(
"--list",
"-l",
action="store_true",
help="List all available configuration options and their descriptions",
)

ignore_parser = subparsers.add_parser("ignore", help="Add pattern to .gitignore")
ignore_parser.add_argument("pattern", help="File pattern (e.g. '*.log')")
Expand Down Expand Up @@ -1146,7 +1278,10 @@ def main() -> None:
tail_log()
return
elif args.command == "config":
open_config()
if getattr(args, "list", False):
show_config_reference()
else:
open_config()
return

# Default Action (if no subcommand is run, or after --env)
Expand Down
104 changes: 96 additions & 8 deletions src/git_pulsar/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
import tomllib
from dataclasses import dataclass, field, replace
from pathlib import Path
Expand All @@ -13,6 +14,39 @@
logger = logging.getLogger(APP_NAME)


def parse_size(value: int | str) -> int:
"""Converts human-readable size strings (e.g., '100MB') to bytes."""
if isinstance(value, int):
return value
match = re.match(r"^(\d+(?:\.\d+)?)\s*([kmg]b?)$", str(value).strip().lower())
if not match:
raise ValueError(f"Invalid size format '{value}'")
num, unit = float(match.group(1)), match.group(2)
multiplier = {
"k": 1024,
"kb": 1024,
"m": 1024**2,
"mb": 1024**2,
"g": 1024**3,
"gb": 1024**3,
}
return int(num * multiplier[unit])


def parse_time(value: int | str) -> int:
"""Converts human-readable time strings (e.g., '1hr', '30m') to seconds."""
if isinstance(value, int):
return value
match = re.match(
r"^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr)s?$", str(value).strip().lower()
)
if not match:
raise ValueError(f"Invalid time format '{value}'")
num, unit = float(match.group(1)), match.group(2)
multiplier = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "hr": 3600}
return int(num * multiplier[unit])


@dataclass
class CoreConfig:
"""Core application settings.
Expand Down Expand Up @@ -45,9 +79,11 @@ class FilesConfig:

Attributes:
ignore (list[str]): List of patterns to ignore (appended to defaults).
manage_gitignore (bool): Whether the daemon is allowed to modify .gitignore.
"""

ignore: list[str] = field(default_factory=list)
manage_gitignore: bool = True


@dataclass
Expand Down Expand Up @@ -84,6 +120,23 @@ def apply_preset(self) -> None:
self.push_interval = 14400 # 4 hours


@dataclass
class EnvConfig:
"""Environment scaffolding settings.

Attributes:
python_version (str): Target Python version for the virtual environment.
venv_dir (str): Name of the virtual environment directory.
generate_vscode_settings (bool): Whether to generate VS Code settings.
generate_direnv (bool): Whether to generate a .envrc file.
"""

python_version: str = "3.12"
venv_dir: str = ".venv"
generate_vscode_settings: bool = True
generate_direnv: bool = True


@dataclass
class Config:
"""Global configuration aggregator.
Expand All @@ -93,12 +146,14 @@ class Config:
limits (LimitsConfig): Resource limits.
files (FilesConfig): File handling settings.
daemon (DaemonConfig): Daemon behavior settings.
env (EnvConfig): Environment bootstrap settings.
"""

core: CoreConfig = field(default_factory=CoreConfig)
limits: LimitsConfig = field(default_factory=LimitsConfig)
files: FilesConfig = field(default_factory=FilesConfig)
daemon: DaemonConfig = field(default_factory=DaemonConfig)
env: EnvConfig = field(default_factory=EnvConfig)

# Cache for the base global configuration
_global_cache: "Config | None" = None
Expand Down Expand Up @@ -146,7 +201,6 @@ def _merge_from_file(self, path: Path, section: str | None = None) -> None:
with open(path, "rb") as f:
data = tomllib.load(f)

# Navigate to the specific section if requested (for pyproject.toml)
if section:
for key in section.split("."):
data = data.get(key, {})
Expand All @@ -156,14 +210,22 @@ def _merge_from_file(self, path: Path, section: str | None = None) -> None:

# Merge Logic
if "core" in data:
self.core = self._update_dataclass(self.core, data["core"])
self.core = self._update_dataclass("core", self.core, data["core"])
if "limits" in data:
self.limits = self._update_dataclass(self.limits, data["limits"])
self.limits = self._update_dataclass(
"limits", self.limits, data["limits"]
)
if "daemon" in data:
self.daemon = self._update_dataclass(self.daemon, data["daemon"])
self.daemon = self._update_dataclass(
"daemon", self.daemon, data["daemon"]
)
self.daemon.apply_preset()
if "env" in data:
self.env = self._update_dataclass("env", self.env, data["env"])
if "files" in data:
new_ignores = data["files"].get("ignore", [])
# Extract ignore list to prevent it from being overwritten during dataclass update
new_ignores = data["files"].pop("ignore", [])
self.files = self._update_dataclass("files", self.files, data["files"])
if new_ignores:
self.files.ignore.extend(new_ignores)
self.files.ignore = list(dict.fromkeys(self.files.ignore))
Expand All @@ -174,8 +236,34 @@ def _merge_from_file(self, path: Path, section: str | None = None) -> None:
logger.warning(f"Failed to load config from {path}: {e}")

@staticmethod
def _update_dataclass(instance: Any, updates: dict) -> Any:
"""Updates a dataclass instance with a dictionary of values."""
def _update_dataclass(section_name: str, instance: Any, updates: dict) -> Any:
"""Updates a dataclass, warning on invalid keys and parsing human-readable formats."""
valid_keys = instance.__dataclass_fields__.keys()
filtered_updates = {k: v for k, v in updates.items() if k in valid_keys}
filtered_updates = {}

# 1. Catch and warn about typos / unknown keys
invalid_keys = set(updates.keys()) - set(valid_keys)
if invalid_keys:
logger.warning(
f"Unknown config keys in [{section_name}]: {', '.join(invalid_keys)}. Ignoring."
)

# 2. Process valid keys
for k, v in updates.items():
if k not in valid_keys:
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)
else:
filtered_updates[k] = v
except ValueError as e:
logger.warning(
f"Config error in [{section_name}].{k}: {e}. Falling back to default."
)

return replace(instance, **filtered_updates)
1 change: 1 addition & 0 deletions src/git_pulsar/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"*.aux",
"*.log",
".DS_Store",
".venv/",
]
"""list[str]: Default file patterns added to .gitignore during repository setup."""

Expand Down
Loading