diff --git a/gitignore_generator/cli.py b/gitignore_generator/cli.py new file mode 100644 index 0000000..fe0d6ab --- /dev/null +++ b/gitignore_generator/cli.py @@ -0,0 +1,276 @@ +"""CLI interface for gitignore-generator.""" + +import builtins +import sys +from typing import Optional + +import click + +from . import __version__ +from .api import get_list, get_patterns, GitignoreIOError +from .cache import CacheManager +from .detector import ProjectDetector +from .generator import GitignoreGenerator + + +def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None: + """Print version information.""" + if not value or ctx.resilient_parsing: + return + click.echo(f"gitignore-generator-cli v{__version__}") + ctx.exit() + + +@click.group() +@click.option( + '--version', + is_flag=True, + callback=print_version, + expose_value=False, + help='Show version information' +) +@click.option( + '--cache-dir', + type=click.Path(), + help='Custom cache directory' +) +@click.pass_context +def main(ctx: click.Context, cache_dir: Optional[str]) -> None: + """Generate .gitignore files for your projects.""" + ctx.ensure_object(dict) + if cache_dir: + ctx.obj['cache_dir'] = cache_dir + + +@main.command() +@click.argument('techs', nargs=-1, required=True) +@click.option( + '-o', '--output', + type=click.Path(), + help='Output file path (default: stdout)' +) +@click.option( + '--preview', + is_flag=True, + help='Preview without writing to file' +) +@click.option( + '--add-pattern', + multiple=True, + help='Add custom pattern' +) +@click.option( + '--force-refresh', + is_flag=True, + help='Force refresh from API, bypass cache' +) +@click.option( + '--merge', + is_flag=True, + help='Merge with existing .gitignore' +) +@click.pass_context +def generate( + ctx: click.Context, + techs: tuple, + output: Optional[str], + preview: bool, + add_pattern: tuple, + force_refresh: bool, + merge: bool +) -> None: + """Generate .gitignore for specified tech stacks. + + Examples: + gitignore-generator generate node python django + gitignore-generator generate node -o .gitignore --preview + gitignore-generator generate node --add-pattern "*.log" + """ + tech_list = [t.lower() for t in techs] + + if preview and output: + click.echo("Error: --preview and -o cannot be used together", err=True) + sys.exit(1) + + generator = GitignoreGenerator() + + if merge and output: + try: + content = generator.merge_with_existing( + output, tech_list, builtins.list(add_pattern), force_refresh + ) + except FileNotFoundError: + click.echo(f"Error: File not found: {output}", err=True) + sys.exit(1) + else: + try: + content = generator.generate_file( + tech_list, output, preview, builtins.list(add_pattern), force_refresh + ) + except GitignoreIOError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + if preview: + click.echo(content) + + if output and not preview: + click.echo(f"Created: {output}") + + +@main.command() +@click.option( + '-o', '--output', + type=click.Path(), + help='Output file path' +) +@click.option( + '--preview', + is_flag=True, + help='Preview without writing' +) +@click.option( + '--force-refresh', + is_flag=True, + help='Force refresh from API' +) +@click.pass_context +def detect( + ctx: click.Context, + output: Optional[str], + preview: bool, + force_refresh: bool +) -> None: + """Auto-detect project type and suggest gitignore. + + Scans current directory for project markers and suggests + appropriate gitignore technologies. + """ + detector = ProjectDetector() + suggestions = detector.suggest_gitignore() + details = detector.get_detection_details() + + if not suggestions: + click.echo("No project type detected. Please specify tech stacks manually.") + return + + click.echo("Detected technologies:") + for detail in details: + tech = detail['technology'] + matched = ', '.join(detail['matched_files'][:3]) + click.echo(f" - {tech}: {matched}") + + click.echo(f"\nSuggested .gitignore for: {', '.join(suggestions)}") + + if preview or output: + generator = GitignoreGenerator() + try: + content = generator.generate_file( + suggestions, output, preview, None, force_refresh + ) + except GitignoreIOError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + if preview: + click.echo("\n--- Generated .gitignore ---") + click.echo(content) + + if output and not preview: + click.echo(f"\nCreated: {output}") + + +@main.command() +@click.argument('search', required=False) +@click.option( + '--force-refresh', + is_flag=True, + help='Force refresh from API' +) +@click.pass_context +def list( + ctx: click.Context, + search: Optional[str], + force_refresh: bool +) -> None: + """List all supported tech stacks. + + Optionally filter by search term. + """ + try: + techs = get_list(force_refresh) + except GitignoreIOError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + if search: + search_lower = search.lower() + techs = [t for t in techs if search_lower in t.lower()] + click.echo(f"Matching '{search}':") + else: + click.echo(f"Supported technologies ({len(techs)}):") + + for tech in sorted(techs): + click.echo(f" - {tech}") + + +@main.command() +@click.argument('tech') +@click.option( + '--force-refresh', + is_flag=True, + help='Force refresh from API' +) +@click.pass_context +def show(ctx: click.Context, tech: str, force_refresh: bool) -> None: + """Show gitignore patterns for a specific tech. + + Useful for previewing patterns before adding to your .gitignore. + """ + try: + patterns = get_patterns(tech.lower(), force_refresh) + click.echo(patterns) + except GitignoreIOError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.pass_context +def cache(ctx: click.Context) -> None: + """Manage cache. + + Show cache statistics or clear cache. + """ + cache = CacheManager() + stats = cache.get_stats() + + click.echo("Cache Statistics:") + click.echo(f" Total items: {stats['total_items']}") + click.echo(f" Valid items: {stats['valid_items']}") + click.echo(f" Expired items: {stats['expired_items']}") + click.echo(f" Cache directory: {stats['cache_dir']}") + + +@main.command() +@click.pass_context +def clear_cache(ctx: click.Context) -> None: + """Clear all cached patterns.""" + cache = CacheManager() + count = cache.clear() + click.echo(f"Cleared {count} cached items.") + + +@main.command() +@click.option( + '--days', + type=int, + default=7, + help='Remove entries older than DAYS (default: 7)' +) +@click.pass_context +def cleanup(ctx: click.Context, days: int) -> None: + """Remove expired cache entries.""" + cache = CacheManager(expiry_days=days) + count = cache.cleanup_expired() + click.echo(f"Cleaned up {count} expired entries.")