diff --git a/pyproject.toml b/pyproject.toml index f2b9a8f..bde9e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ test = [ "pytest>=8.2.2", "pytest-cov>=5.0.0", ] +format = [ + "black>=24.10.0", +] [project.urls] "Homepage" = "https://github.com/jahwag/claudesync" diff --git a/src/claudesync/cli/hook_templates/pre_commit.py b/src/claudesync/cli/hook_templates/pre_commit.py new file mode 100644 index 0000000..6afdf4c --- /dev/null +++ b/src/claudesync/cli/hook_templates/pre_commit.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys + + +def get_changed_files() -> list[str]: + """Get list of Python files that are staged for commit.""" + try: + cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + files = result.stdout.strip().split("\n") + return [f for f in files if f.endswith(".py") and os.path.exists(f)] + except subprocess.CalledProcessError: + print("Failed to get changed files") + return [] + + +def format_files(files: list[str]) -> tuple[bool, list[str]]: + """Format the given files using black.""" + if not files: + return True, [] + + formatted_files = [] + try: + subprocess.run(["black", "--version"], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + print( + "Error: black is not installed. Please install it with: pip install black" + ) + return False, [] + + for file_path in files: + try: + subprocess.run( + ["black", "--quiet", file_path], capture_output=True, check=True + ) + formatted_files.append(file_path) + except subprocess.CalledProcessError as e: + print(f"Failed to format {file_path}: {e}") + return False, formatted_files + + return True, formatted_files + + +def main(): + files = get_changed_files() + if not files: + print("No Python files to format") + sys.exit(0) + + print("Formatting Python files with black...") + success, formatted_files = format_files(files) + + if formatted_files: + print("\nFormatted files:") + for file in formatted_files: + print(f" - {file}") + + # Re-stage formatted files + try: + subprocess.run(["git", "add"] + formatted_files, check=True) + print("\nFormatted files have been re-staged") + except subprocess.CalledProcessError: + print("Failed to re-stage formatted files") + sys.exit(1) + + if not success: + print("\nSome files could not be formatted") + sys.exit(1) + + print("\nAll files formatted successfully") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/claudesync/cli/hooks.py b/src/claudesync/cli/hooks.py new file mode 100644 index 0000000..68e5a3d --- /dev/null +++ b/src/claudesync/cli/hooks.py @@ -0,0 +1,90 @@ +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import click + +SUPPORTED_GIT_HOOKS = [ + "pre-commit", +] + + +@click.group() +def hooks(): + """Manage Git hooks for the project.""" + pass + + +@hooks.command() +def install(): + """Install Git hooks for automatic code formatting.""" + project_root = find_git_root() + if not project_root: + click.echo("Error: Not a git repository (or any of the parent directories)") + return + + hooks_dir = project_root / ".git" / "hooks" + if not hooks_dir.exists(): + click.echo(f"Creating hooks directory: {hooks_dir}") + hooks_dir.mkdir(parents=True, exist_ok=True) + + for hook in SUPPORTED_GIT_HOOKS: + copy_hook(hooks_dir, hook) + + click.echo("\nHook installation complete!") + + +@hooks.command() +def list(): + """List available Git hooks.""" + click.echo("Available hooks:") + for hook in SUPPORTED_GIT_HOOKS: + click.echo(f" - {hook}") + + +def copy_hook(hooks_dir, hook_name): + """ + Copy a specific hook from templates to git hooks directory. + + Args: + hooks_dir: Path to the .git/hooks directory + hook_name: Name of the hook to install + """ + hook_path = hooks_dir / hook_name + module_dir = Path(__file__).parent + template_name = f"{hook_name.replace('-', '_')}.py" + hook_template = module_dir / "hook_templates" / template_name + + if not hook_template.exists(): + click.echo(f"Warning: Template for {hook_name} not found at {hook_template}") + return + + try: + shutil.copy2(hook_template, hook_path) + + st = os.stat(hook_path) + os.chmod(hook_path, st.st_mode | stat.S_IEXEC) + + click.echo(f"Successfully installed {hook_name} hook") + except Exception as e: + click.echo(f"Error installing {hook_name} hook: {e}") + + +def find_git_root(): + """Find the root directory of the Git repository""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + return Path(result.stdout.strip()) + except subprocess.CalledProcessError: + return None + + +if __name__ == "__main__": + install() diff --git a/src/claudesync/cli/main.py b/src/claudesync/cli/main.py index ac3a477..65f5134 100644 --- a/src/claudesync/cli/main.py +++ b/src/claudesync/cli/main.py @@ -21,6 +21,7 @@ from .project import project from .sync import schedule from .config import config +from .hooks import hooks import logging logging.basicConfig( @@ -213,6 +214,7 @@ def sync_submodule(provider, config, submodule, category): cli.add_command(schedule) cli.add_command(config) cli.add_command(chat) +cli.add_command(hooks) if __name__ == "__main__": cli()