From 77ed8415db34ac1891a0971c16263fb4a7202137 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 21:10:01 -0800 Subject: [PATCH 1/9] feat(config): add EnvConfig dataclass for bootstrap parameters Extends the global configuration model to support customizable parameters for the `--env` bootstrap command. Introduces the `EnvConfig` dataclass to handle Python version targeting, virtual environment directory naming, and boolean flags for opting out of VS Code and direnv generation. This ensures the environment scaffolding is decoupled from hardcoded strings and can participate in the cascading TOML configuration hierarchy. --- src/git_pulsar/config.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/git_pulsar/config.py b/src/git_pulsar/config.py index 028adcd..8463081 100644 --- a/src/git_pulsar/config.py +++ b/src/git_pulsar/config.py @@ -84,6 +84,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. @@ -93,12 +110,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 @@ -167,6 +186,8 @@ def _merge_from_file(self, path: Path, section: str | None = None) -> None: if new_ignores: self.files.ignore.extend(new_ignores) self.files.ignore = list(dict.fromkeys(self.files.ignore)) + if "env" in data: + self.env = self._update_dataclass(self.env, data["env"]) except tomllib.TOMLDecodeError as e: logger.error(f"Config syntax error in {path}: {e}") From 4821ed9da8af6cf9e74d6df58be77e63fff95920 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 21:12:23 -0800 Subject: [PATCH 2/9] refactor(ops): replace hardcoded bootstrap values with Config parameters Updates `bootstrap_env` to load the active `Config` instance and utilize the new `EnvConfig` parameters. - Dynamically injects `config.env.python_version` into the `uv init` call. - Dynamically injects `config.env.venv_dir` into the `.envrc` and VS Code settings generation templates. - Wraps the direnv and VS Code generation blocks in conditionals based on the respective boolean flags in `EnvConfig`. --- src/git_pulsar/ops.py | 103 ++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index e35e8bc..95d87ac 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -185,8 +185,9 @@ def set_drift_state( def bootstrap_env() -> None: """Bootstraps a Python development environment on macOS. - This function scaffolds the environment using `uv` for package management, - `direnv` for environment switching, and configures VS Code settings. + This function scaffolds the environment using `uv` for package management. + Optionally configures `direnv` for environment switching and generates + VS Code settings based on the active configuration. Note: This workflow is currently optimized for macOS. @@ -199,6 +200,8 @@ def bootstrap_env() -> None: return cwd = Path.cwd() + config = Config.load(cwd) + console.print( f"[bold blue]SETUP:[/bold blue] Setting up dev environment in {cwd.name}..." ) @@ -207,7 +210,7 @@ def bootstrap_env() -> None: missing = [] if not shutil.which("uv"): missing.append("uv") - if not shutil.which("direnv"): + if config.env.generate_direnv and not shutil.which("direnv"): missing.append("direnv") if missing: @@ -225,60 +228,64 @@ def bootstrap_env() -> None: # 2. Project Scaffold (uv) if not (cwd / "pyproject.toml").exists(): console.print("[bold blue]INIT:[/bold blue] Initializing Python project...") - # 'uv init' creates a standard pyproject.toml. - subprocess.run(["uv", "init", "--no-workspace", "--python", "3.12"], check=True) + subprocess.run( + ["uv", "init", "--no-workspace", "--python", config.env.python_version], + check=True, + ) else: console.print(" Existing pyproject.toml found. Skipping init.") # 3. Direnv Configuration - envrc_path = cwd / ".envrc" - if not envrc_path.exists(): - console.print("[bold blue]CONFIG:[/bold blue] Creating .envrc...") - envrc_content = textwrap.dedent("""\ - # Auto-generated by git-pulsar - if [ ! -d ".venv" ]; then - echo "Creating virtual environment..." - uv sync - fi - source .venv/bin/activate - - source_env_if_exists .envrc.local - """) - with open(envrc_path, "w") as f: - f.write(envrc_content) - - subprocess.run(["direnv", "allow"], check=True) - else: - console.print(" .envrc exists. Skipping.") + if config.env.generate_direnv: + envrc_path = cwd / ".envrc" + if not envrc_path.exists(): + console.print("[bold blue]CONFIG:[/bold blue] Creating .envrc...") + envrc_content = textwrap.dedent(f"""\ + # Auto-generated by git-pulsar + if [ ! -d "{config.env.venv_dir}" ]; then + echo "Creating virtual environment..." + uv sync + fi + source {config.env.venv_dir}/bin/activate + + source_env_if_exists .envrc.local + """) + with open(envrc_path, "w") as f: + f.write(envrc_content) + + subprocess.run(["direnv", "allow"], check=True) + else: + console.print(" .envrc exists. Skipping.") # 4. VS Code Settings - vscode_dir = cwd / ".vscode" - settings_path = vscode_dir / "settings.json" - - if not settings_path.exists(): - vscode_dir.mkdir(exist_ok=True) - console.print("[bold blue]CONFIG:[/bold blue] Configuring VS Code...") - settings_content = textwrap.dedent("""\ - { - "python.defaultInterpreterPath": ".venv/bin/python", - "python.terminal.activateEnvironment": true, - "files.exclude": { - "**/__pycache__": true, - "**/.ipynb_checkpoints": true, - "**/.DS_Store": true, - "**/.venv": true - }, - "search.exclude": { - "**/.venv": true - } - } - """) - with open(settings_path, "w") as f: - f.write(settings_content) + if config.env.generate_vscode_settings: + vscode_dir = cwd / ".vscode" + settings_path = vscode_dir / "settings.json" + + if not settings_path.exists(): + vscode_dir.mkdir(exist_ok=True) + console.print("[bold blue]CONFIG:[/bold blue] Configuring VS Code...") + settings_content = textwrap.dedent(f"""\ + {{ + "python.defaultInterpreterPath": "{config.env.venv_dir}/bin/python", + "python.terminal.activateEnvironment": true, + "files.exclude": {{ + "**/__pycache__": true, + "**/.ipynb_checkpoints": true, + "**/.DS_Store": true, + "**/{config.env.venv_dir}": true + }}, + "search.exclude": {{ + "**/{config.env.venv_dir}": true + }} + }} + """) + with open(settings_path, "w") as f: + f.write(settings_content) console.print("\n[bold green]SUCCESS:[/bold green] Environment ready.") - if "DIRENV_DIR" not in os.environ: + if config.env.generate_direnv and "DIRENV_DIR" not in os.environ: console.print("\n[bold yellow]ACTION REQUIRED:[/bold yellow] Enable direnv") console.print(" 1. Open your config:") console.print(" code ~/.zshrc (or nano ~/.zshrc)") From 7347e5a5dcd67ad78acdf596e028996f48ababc2 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 21:20:41 -0800 Subject: [PATCH 3/9] fix(env): explicitly ignore venv directories to prevent index bloat Injects a dynamic call to `add_ignore()` in the `--env` bootstrap sequence to automatically untrack the configured virtual environment directory (`config.env.venv_dir`). Additionally appends `".venv/"` to `DEFAULT_IGNORES` in `constants.py` as a fallback safety measure for standard repository initialization. --- src/git_pulsar/constants.py | 1 + src/git_pulsar/ops.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/git_pulsar/constants.py b/src/git_pulsar/constants.py index 6c82046..a582e54 100644 --- a/src/git_pulsar/constants.py +++ b/src/git_pulsar/constants.py @@ -58,6 +58,7 @@ "*.aux", "*.log", ".DS_Store", + ".venv/", ] """list[str]: Default file patterns added to .gitignore during repository setup.""" diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index 95d87ac..8db2104 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -235,6 +235,9 @@ def bootstrap_env() -> None: else: console.print(" Existing pyproject.toml found. Skipping init.") + # 2.5 Safety: Explicitly ignore the configured venv directory + add_ignore(f"{config.env.venv_dir}/") + # 3. Direnv Configuration if config.env.generate_direnv: envrc_path = cwd / ".envrc" From 788c457921badbbba831e799e06c1bbd06c02054 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 21:28:08 -0800 Subject: [PATCH 4/9] test(ops): update bootstrap tests for dynamic env configuration Refactors `test_bootstrap_env_scaffolds_files` to mock the active `Config` state rather than relying on hardcoded defaults. - Asserts that the dynamically assigned `venv_dir` is correctly injected into the generated `.envrc` and VS Code settings files. - Verifies that `add_ignore` is successfully called with the custom virtual environment directory path to prevent index bloat. --- tests/test_ops.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_ops.py b/tests/test_ops.py index f2eb289..20b43ed 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -47,8 +47,9 @@ def test_bootstrap_env_scaffolds_files(tmp_path: Path, mocker: MagicMock) -> Non """Verifies that `bootstrap_env` creates the necessary configuration files. Checks for: - 1. Execution of `uv init`. - 2. Creation of `.envrc` with activation logic. + 1. Execution of `uv init` with configured python version. + 2. Creation of `.envrc` and VS Code settings using configured venv_dir. + 3. Injection of venv_dir into .gitignore via add_ignore to prevent index bloat. Args: tmp_path (Path): Pytest fixture for a temporary directory. @@ -60,15 +61,35 @@ def test_bootstrap_env_scaffolds_files(tmp_path: Path, mocker: MagicMock) -> Non mock_run = mocker.patch("subprocess.run") mocker.patch("git_pulsar.ops.console") + # 1. Mock the Config payload to use a custom venv_dir and python version + mock_config = mocker.patch("git_pulsar.ops.Config").load.return_value + mock_config.env.python_version = "3.12" + mock_config.env.venv_dir = ".custom_venv" + mock_config.env.generate_direnv = True + mock_config.env.generate_vscode_settings = True + + # 2. Mock add_ignore so we can verify it gets called + mock_add_ignore = mocker.patch("git_pulsar.ops.add_ignore") + ops.bootstrap_env() + # Assert `uv init` used the configured python version mock_run.assert_any_call( ["uv", "init", "--no-workspace", "--python", "3.12"], check=True ) + # Assert .envrc was created with the custom venv directory envrc = tmp_path / ".envrc" assert envrc.exists() - assert "source .venv/bin/activate" in envrc.read_text() + assert "source .custom_venv/bin/activate" in envrc.read_text() + + # Assert VS Code settings were created with the custom venv directory + settings = tmp_path / ".vscode" / "settings.json" + assert settings.exists() + assert ".custom_venv/bin/python" in settings.read_text() + + # Assert our new safety measure triggered correctly + mock_add_ignore.assert_called_once_with(".custom_venv/") # Restore / Sync Tests From 44c5d8e990aa431193da4a5ac0de115571aa703e Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 21:33:32 -0800 Subject: [PATCH 5/9] feat(config): add manage_gitignore flag to respect user boundaries Introduces the `manage_gitignore` boolean flag in `FilesConfig` to allow users to opt out of automated `.gitignore` modifications. - Wraps the default ignore scaffolding in `cli.py`'s `setup_repo()` behind the flag constraint. - Conditionally bypasses the file write step in `ops.py`'s `add_ignore()` while preserving the index tracking checks. This ensures Git Pulsar operates non-destructively for power users who prefer strict, manual control over their repository ignore state. --- src/git_pulsar/cli.py | 46 +++++++++++++++++++++++----------------- src/git_pulsar/config.py | 2 ++ src/git_pulsar/ops.py | 29 +++++++++++++++---------- 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 4f2c4ad..aa26406 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -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(): @@ -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") diff --git a/src/git_pulsar/config.py b/src/git_pulsar/config.py index 8463081..c580746 100644 --- a/src/git_pulsar/config.py +++ b/src/git_pulsar/config.py @@ -45,9 +45,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 diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index 8db2104..c98f4cd 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -610,27 +610,34 @@ def add_ignore(pattern: str) -> None: If files matching the pattern are currently tracked, the user is prompted to stop tracking them (while keeping the files on disk). + Respects the config.files.manage_gitignore flag. Args: pattern (str): The file pattern to ignore (e.g., '*.log'). """ cwd = Path.cwd() + config = Config.load(cwd) gitignore = cwd / ".gitignore" - # 1. Append to .gitignore if not present. - content = "" - if gitignore.exists(): - with open(gitignore) as f: - content = f.read() + # 1. Append to .gitignore if not present (and allowed by config). + if config.files.manage_gitignore: + content = "" + if gitignore.exists(): + with open(gitignore) as f: + content = f.read() - if pattern in content: - console.print(f"[blue]INFO:[/blue] '{pattern}' is already in .gitignore.") + if pattern in content: + console.print(f"[blue]INFO:[/blue] '{pattern}' is already in .gitignore.") + else: + with open(gitignore, "a") as f: + prefix = "\n" if content and not content.endswith("\n") else "" + f.write(f"{prefix}{pattern}\n") + console.print( + f"[bold green]SUCCESS:[/bold green] Added '{pattern}' to .gitignore." + ) else: - with open(gitignore, "a") as f: - prefix = "\n" if content and not content.endswith("\n") else "" - f.write(f"{prefix}{pattern}\n") console.print( - f"[bold green]SUCCESS:[/bold green] Added '{pattern}' to .gitignore." + f"[dim]INFO: Skipping .gitignore update for '{pattern}' (manage_gitignore=false).[/dim]" ) # 2. Check if currently tracked and offer to remove from index. From 7c8736ab9eb6dc1342618b08fe7c7bcfc975dab6 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 22:02:29 -0800 Subject: [PATCH 6/9] feat(cli): add --list flag to config command for terminal reference Introduces `git pulsar config --list` to provide a zero-friction way to view the configuration schema directly in the terminal. - Renders a rich table containing all sections, keys, types, defaults, and descriptions. - Keeps the primary `git pulsar help` output clean by isolating the verbose configuration matrix to a specific subcommand flag. --- src/git_pulsar/cli.py | 123 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index aa26406..1e244b6 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -1009,6 +1009,113 @@ 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", "600", "Seconds between local state captures." + ) + table.add_row("", "push_interval", "int", "3600", "Seconds between remote pushes.") + 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", + "5242880", + "Max bytes for log files before rotation (default: 5MB).", + ) + table.add_row( + "", + "large_file_threshold", + "int", + "104857600", + "Max file size before aborting a backup (default: 100MB).", + ) + + # Env Settings (from our previous implementation) + 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( @@ -1075,7 +1182,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')") @@ -1152,7 +1268,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) From f6547bb3af0d78a359871b6cf82daeef3f418024 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 22:16:31 -0800 Subject: [PATCH 7/9] feat(config): add human-readable time/size parsing and key validation Introduces flexible string parsing for configuration values, allowing users to define intervals (e.g., "10 min", "1 hr") and file limits (e.g., "100mb", "1.5gb") intuitively. - Adds `parse_size` and `parse_time` regex-based helper functions. - Refactors `_update_dataclass` to intercept updates, warn on unknown keys, and safely fall back to defaults if parsing fails. - Modifies the `.gitignore` parsing logic in `_merge_from_file` to cleanly separate list extension from dataclass field updates. --- src/git_pulsar/config.py | 85 +++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/src/git_pulsar/config.py b/src/git_pulsar/config.py index c580746..eb60cf7 100644 --- a/src/git_pulsar/config.py +++ b/src/git_pulsar/config.py @@ -1,4 +1,5 @@ import logging +import re import tomllib from dataclasses import dataclass, field, replace from pathlib import Path @@ -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. @@ -167,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, {}) @@ -177,19 +210,25 @@ 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)) - if "env" in data: - self.env = self._update_dataclass(self.env, data["env"]) except tomllib.TOMLDecodeError as e: logger.error(f"Config syntax error in {path}: {e}") @@ -197,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) From 6cf6d440929cc71d400185f93ed2ae5b17f4243e Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 22:19:51 -0800 Subject: [PATCH 8/9] test(config): add tests for string parsing and robust fallback logic Introduces unit tests for the new `parse_time` and `parse_size` helpers to ensure human-readable formats and whitespace variations are correctly converted to operational integers. - Verifies conversion logic for bytes (KB, MB, GB) and seconds (s, m, h). - Implements strict `pytest.raises` matching to satisfy Ruff's PT011 rule and prevent false positives on generic ValueErrors. - Adds `test_config_invalid_keys_and_values` to verify the configuration engine successfully intercepts garbage data, logs targeted warnings, and preserves safe defaults without crashing. --- tests/test_config.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index 2afecfb..c5506ba 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -90,3 +90,66 @@ def test_config_load_from_pyproject(tmp_path: Path) -> None: assert conf.core.remote_name == "backup" assert conf.daemon.preset == "paranoid" assert conf.daemon.commit_interval == 300 + + +def test_parse_size() -> None: + """Verifies that human-readable sizes are correctly converted to bytes.""" + from git_pulsar.config import parse_size + + assert parse_size(100) == 100 + assert parse_size("100kb") == 102400 + assert parse_size("10 MB") == 10485760 + assert parse_size("1.5gb") == int(1.5 * 1024**3) + + with pytest.raises(ValueError, match=r"Invalid size format '100 bits'"): + parse_size("100 bits") + + +def test_parse_time() -> None: + """Verifies that human-readable times are correctly converted to seconds.""" + from git_pulsar.config import parse_time + + assert parse_time(50) == 50 + assert parse_time("30s") == 30 + assert parse_time("10 min") == 600 + assert parse_time("2 hrs") == 7200 + assert parse_time("1.5h") == 5400 + + with pytest.raises(ValueError, match=r"Invalid time format '10 lightyears'"): + parse_time("10 lightyears") + + +def test_config_invalid_keys_and_values( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Verifies that unknown keys are ignored and invalid values fallback to defaults. + + Args: + tmp_path (Path): Pytest fixture for a temporary directory. + caplog (pytest.LogCaptureFixture): Pytest fixture for capturing logs. + """ + import logging + + caplog.set_level(logging.WARNING) + + local_toml = tmp_path / "pulsar.toml" + local_toml.write_text( + "[daemon]\n" + 'commit_interval = "fast"\n' + 'fake_setting = "ignored"\n' + "[limits]\n" + 'max_log_size = "10 gallons"\n' + ) + + conf = Config.load(repo_path=tmp_path) + + # Assert fallbacks to defaults + assert conf.daemon.commit_interval == 600 + assert conf.limits.max_log_size == 5242880 + + # Assert warnings were logged + assert "Unknown config keys in [daemon]: fake_setting" in caplog.text + assert ( + "Config error in [daemon].commit_interval: Invalid time format" in caplog.text + ) + assert "Config error in [limits].max_log_size: Invalid size format" in caplog.text From b945493a79027129a178bc1f6ea277249f580b21 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Tue, 24 Feb 2026 22:20:04 -0800 Subject: [PATCH 9/9] docs(cli): update config reference table with new string formats Updates the `git pulsar config --list` output to accurately reflect the new parsing capabilities introduced in the configuration engine. - Changes type hints for interval and threshold settings to `int | str`. - Updates the displayed defaults to their human-readable equivalents (e.g., `"10m"`, `"100mb"`). - Expands descriptions to include syntax examples for the time and size parsers. --- src/git_pulsar/cli.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 1e244b6..cca0cfb 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -1041,9 +1041,19 @@ def show_config_reference() -> None: "Interval preset: 'paranoid', 'aggressive', 'balanced', or 'lazy'.", ) table.add_row( - "", "commit_interval", "int", "600", "Seconds between local state captures." + "", + "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("", "push_interval", "int", "3600", "Seconds between remote pushes.") table.add_row( "", "min_battery_percent", @@ -1075,19 +1085,19 @@ def show_config_reference() -> None: table.add_row( "limits", "max_log_size", - "int", - "5242880", - "Max bytes for log files before rotation (default: 5MB).", + "int | str", + '"5mb"', + "Max size for log files before rotation (e.g., '5mb', '1gb').", ) table.add_row( "", "large_file_threshold", - "int", - "104857600", - "Max file size before aborting a backup (default: 100MB).", + "int | str", + '"100mb"', + "Max file size before aborting a backup (e.g., '100mb', '2gb').", ) - # Env Settings (from our previous implementation) + # Env Settings table.add_row( "env", "python_version",