diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..9968a08 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,268 @@ +"""CLI interface for auto-gitignore.""" + +import sys +from pathlib import Path +from typing import Optional, Tuple + +import click + +from src.detectors.framework import FrameworkDetector +from src.detectors.ide import IDEDetector +from src.detectors.language import LanguageDetector +from src.generators.merge import PatternMerger +from src.generators.pattern import PatternGenerator + + +LANGUAGE_CHOICES = [ + "python", "nodejs", "go", "java", "rust", "csharp", "cpp", + "ruby", "php", "dart", "swift", "kotlin", "scala", "perl", + "r", "elixir", "clojure", "lua", "haskell", +] + +FRAMEWORK_CHOICES = [ + "django", "flask", "fastapi", "react", "vue", "angular", + "express", "nextjs", "nuxt", "svelte", "gatsby", "astro", + "gin", "spring", "rails", "laravel", "dotnet", "quasar", + "sveltekit", "remix", "vite", "nestjs", +] + +IDE_CHOICES = [ + "vscode", "jetbrains", "pycharm", "webstorm", "intellij", + "eclipse", "netbeans", "sublime", "vim", "emacs", "atom", "spacemacs", +] + +OS_CHOICES = ["macos", "windows", "linux"] + + +@click.command() +@click.option( + "--path", + "-p", + type=click.Path(exists=True, file_okay=False, dir_okay=True), + default=".", + help="Path to project directory (default: current directory)", +) +@click.option( + "--language", + "-l", + multiple=True, + type=click.Choice(LANGUAGE_CHOICES), + help="Force specific language(s)", +) +@click.option( + "--framework", + "-f", + multiple=True, + type=click.Choice(FRAMEWORK_CHOICES), + help="Add framework-specific patterns", +) +@click.option( + "--ide", + multiple=True, + type=click.Choice(IDE_CHOICES), + help="Add IDE-specific patterns", +) +@click.option( + "--os", + multiple=True, + type=click.Choice(OS_CHOICES), + help="Add OS-specific patterns", +) +@click.option( + "--custom", + "-c", + multiple=True, + help="Add custom pattern(s)", +) +@click.option( + "--output", + "-o", + type=click.Path(dir_okay=False), + help="Output file path (default: .gitignore in project path)", +) +@click.option( + "--merge/--no-merge", + default=True, + help="Merge patterns from multiple sources", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show generated content without writing file", +) +@click.option( + "--force", + is_flag=True, + default=False, + help="Overwrite existing .gitignore file", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + default=False, + help="Show detected stack and other details", +) +def main( + path: str, + language: Tuple[str, ...], + framework: Tuple[str, ...], + ide: Tuple[str, ...], + os: Tuple[str, ...], + custom: Tuple[str, ...], + output: Optional[str], + merge: bool, + dry_run: bool, + force: bool, + verbose: bool, +) -> None: + """Auto-generate .gitignore files based on project type and stack.""" + project_path = Path(path).resolve() + + if output is None: + output_path = project_path / ".gitignore" + else: + output_path = Path(output) + + languages = list(language) + frameworks = list(framework) + ides = list(ide) + os_patterns_list = list(os) + custom_patterns = list(custom) if custom else [] + + if not languages or not frameworks: + detected = detect_project_stack(project_path) + if not languages: + languages = detected.get("languages", []) + if not frameworks: + frameworks = detected.get("frameworks", []) + if not ides: + ides = detected.get("ides", []) + if not os_patterns_list: + os_patterns_list = detected.get("os", ["macos", "windows", "linux"]) + + if verbose: + click.echo(f"Project path: {project_path}") + if languages: + click.echo(f"Detected languages: {', '.join(languages)}") + if frameworks: + click.echo(f"Detected frameworks: {', '.join(frameworks)}") + if ides: + click.echo(f"Detected IDEs: {', '.join(ides)}") + click.echo("") + + generator = PatternGenerator() + merger = PatternMerger() + + if merge and (languages or frameworks or ides): + patterns_by_category: dict = {} + if languages: + patterns_by_category["language"] = [] + for lang in languages: + patterns_by_category["language"].extend( + generator.language_patterns.get(lang, []) + ) + if frameworks: + patterns_by_category["framework"] = [] + for fw in frameworks: + patterns_by_category["framework"].extend( + generator.framework_patterns.get(fw, []) + ) + if ides: + patterns_by_category["ide"] = [] + for ide_name in ides: + patterns_by_category["ide"].extend( + generator.ide_patterns.get(ide_name, []) + ) + if os_patterns_list: + patterns_by_category["os"] = [] + for os_name in os_patterns_list: + patterns_by_category["os"].extend( + generator.os_patterns.get(os_name, []) + ) + + merged_patterns, conflicts = merger.merge(patterns_by_category, custom_patterns) + + if conflicts and verbose: + click.echo("Conflicts resolved:", err=True) + for conflict in conflicts: + click.echo(f" - {conflict}", err=True) + + content = "\n".join(merged_patterns) + else: + content = generator.generate( + languages=languages, + frameworks=frameworks, + ides=ides, + os_list=list(os_patterns_list), + custom_patterns=custom_patterns, + ) + + if not content.strip(): + content = "# No patterns generated. Use --language, --framework, or --custom to add patterns." + + if dry_run: + click.echo(content) + return + + if output_path.exists() and not force: + click.echo(f"Error: {output_path} already exists. Use --force to overwrite.", err=True) + sys.exit(1) + + try: + output_path.write_text(content) + click.echo(f"Generated .gitignore at {output_path}") + + if verbose: + line_count = len(content.splitlines()) + click.echo(f"Total lines: {line_count}") + except PermissionError: + click.echo(f"Error: Permission denied writing to {output_path}", err=True) + sys.exit(1) + except OSError as e: + click.echo(f"Error writing to {output_path}: {e}", err=True) + sys.exit(1) + + +def detect_project_stack(path: Path) -> dict: + """Detect project languages, frameworks, and IDEs. + + Args: + path: Project root path. + + Returns: + Dict with detected languages, frameworks, and ides. + """ + language_detector = LanguageDetector() + framework_detector = FrameworkDetector() + ide_detector = IDEDetector() + + languages = language_detector.detect(path) + frameworks = framework_detector.detect(path) + + frameworks_from_content = framework_detector.detect_from_content(path) + for fw in frameworks_from_content: + if fw not in frameworks: + frameworks.append(fw) + + package_frameworks = framework_detector.detect_package_json_frameworks(path) + for fw in package_frameworks: + if fw not in frameworks: + frameworks.append(fw) + + ides = ide_detector.detect(path) + + if not ides: + if ide_detector.detect_vscode(path): + ides.append("vscode") + if ide_detector.detect_jetbrains(path): + if "pycharm" not in ides: + ides.append("jetbrains") + + return { + "languages": languages, + "frameworks": frameworks, + "ides": ides, + "os": ["macos", "windows", "linux"], + }