diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 4f2c4ad..cca0cfb 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") @@ -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( @@ -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')") @@ -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) diff --git a/src/git_pulsar/config.py b/src/git_pulsar/config.py index 028adcd..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. @@ -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 @@ -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. @@ -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 @@ -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, {}) @@ -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)) @@ -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) 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 e35e8bc..c98f4cd 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,67 @@ 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.") + # 2.5 Safety: Explicitly ignore the configured venv directory + add_ignore(f"{config.env.venv_dir}/") + # 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)") @@ -600,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() - - if pattern in content: - console.print(f"[blue]INFO:[/blue] '{pattern}' is already in .gitignore.") + # 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.") + 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. 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 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