smart-commit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. smart_commit/__init__.py +6 -0
  2. smart_commit/ai/__init__.py +7 -0
  3. smart_commit/ai/client.py +123 -0
  4. smart_commit/ai/reviewer.py +91 -0
  5. smart_commit/ai.py +136 -0
  6. smart_commit/analysis/__init__.py +7 -0
  7. smart_commit/analysis/ast_parser.py +173 -0
  8. smart_commit/analysis/dependency_graph.py +114 -0
  9. smart_commit/analysis/diff_parser.py +172 -0
  10. smart_commit/analysis/summarizer.py +101 -0
  11. smart_commit/analysis/symbol_extractor.py +88 -0
  12. smart_commit/benchmark.py +88 -0
  13. smart_commit/cache.py +65 -0
  14. smart_commit/cli.py +391 -0
  15. smart_commit/clustering/__init__.py +7 -0
  16. smart_commit/clustering/graph_clustering.py +78 -0
  17. smart_commit/clustering/heuristic.py +178 -0
  18. smart_commit/clustering/semantic.py +201 -0
  19. smart_commit/clustering.py +186 -0
  20. smart_commit/commit_messages.py +232 -0
  21. smart_commit/committer.py +91 -0
  22. smart_commit/config.py +88 -0
  23. smart_commit/constants.py +92 -0
  24. smart_commit/diff_parser.py +158 -0
  25. smart_commit/embeddings/__init__.py +7 -0
  26. smart_commit/embeddings/generator.py +128 -0
  27. smart_commit/embeddings/similarity.py +159 -0
  28. smart_commit/embeddings.py +108 -0
  29. smart_commit/exceptions.py +102 -0
  30. smart_commit/execution/__init__.py +6 -0
  31. smart_commit/execution/committer.py +102 -0
  32. smart_commit/git/__init__.py +8 -0
  33. smart_commit/git/safety.py +75 -0
  34. smart_commit/git/scanner.py +182 -0
  35. smart_commit/git/service.py +129 -0
  36. smart_commit/git_service.py +302 -0
  37. smart_commit/logger.py +99 -0
  38. smart_commit/models.py +73 -0
  39. smart_commit/plugins/__init__.py +6 -0
  40. smart_commit/plugins/base.py +135 -0
  41. smart_commit/plugins/django.py +40 -0
  42. smart_commit/plugins/fastapi.py +39 -0
  43. smart_commit/plugins/react.py +49 -0
  44. smart_commit/preview/__init__.py +6 -0
  45. smart_commit/preview/editor.py +224 -0
  46. smart_commit/preview/renderer.py +94 -0
  47. smart_commit/preview.py +131 -0
  48. smart_commit/summarizer.py +93 -0
  49. smart_commit/utils.py +83 -0
  50. smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
  51. smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
  52. smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
  53. smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,172 @@
1
+ import re
2
+ from ..models import FileDiff
3
+ from ..logger import get_logger
4
+
5
+ logger = get_logger("analysis.diff_parser")
6
+
7
+
8
+ class DiffParser:
9
+ """Parses git diffs with enhanced language awareness."""
10
+
11
+ # Language-specific keywords
12
+ LANGUAGE_PATTERNS = {
13
+ ".py": {
14
+ "keywords": ["def ", "class ", "async def ", "import ", "from "],
15
+ "symbols": r"(?:def|class|async def)\s+(\w+)",
16
+ },
17
+ ".js": {
18
+ "keywords": ["function ", "class ", "const ", "import ", "export "],
19
+ "symbols": r"(?:function|class)\s+(\w+)|(?:const|let|var)\s+(\w+)",
20
+ },
21
+ ".ts": {
22
+ "keywords": ["interface ", "type ", "class ", "function ", "export "],
23
+ "symbols": r"(?:interface|type|class|function)\s+(\w+)",
24
+ },
25
+ ".tsx": {
26
+ "keywords": ["interface ", "function ", "const ", "export "],
27
+ "symbols": r"(?:interface|function)\s+(\w+)|(?:const)\s+(\w+)",
28
+ },
29
+ ".jsx": {
30
+ "keywords": ["function ", "export ", "const ", "import "],
31
+ "symbols": r"(?:function|const)\s+(\w+)",
32
+ },
33
+ ".java": {
34
+ "keywords": ["class ", "public ", "private ", "interface "],
35
+ "symbols": r"(?:class|interface)\s+(\w+)",
36
+ },
37
+ ".go": {
38
+ "keywords": ["func ", "type ", "package ", "import "],
39
+ "symbols": r"func\s+(?:\(\w+\s+\*?\w+\))?\s*(\w+)",
40
+ },
41
+ ".rs": {
42
+ "keywords": ["fn ", "struct ", "impl ", "pub ", "use "],
43
+ "symbols": r"(?:fn|struct|impl)\s+(\w+)",
44
+ },
45
+ }
46
+
47
+ def parse_diff(self, raw_diff: str, file_path: str) -> list[str]:
48
+ """Extract meaningful changes from a raw diff.
49
+
50
+ Args:
51
+ raw_diff: Raw diff content.
52
+ file_path: Path to file.
53
+
54
+ Returns:
55
+ List of change descriptions.
56
+ """
57
+ if not raw_diff.strip():
58
+ return []
59
+
60
+ changes = []
61
+ file_ext = self._get_file_extension(file_path)
62
+
63
+ # Parse diff lines
64
+ lines = raw_diff.split('\n')
65
+ added_lines = [l[1:] for l in lines if l.startswith('+') and not l.startswith('+++')]
66
+ removed_lines = [l[1:] for l in lines if l.startswith('-') and not l.startswith('---')]
67
+
68
+ # High-level change type
69
+ if added_lines and not removed_lines:
70
+ changes.append("Added new content")
71
+ elif removed_lines and not added_lines:
72
+ changes.append("Removed content")
73
+ elif added_lines and removed_lines:
74
+ changes.append("Modified content")
75
+
76
+ patterns = self.LANGUAGE_PATTERNS.get(file_ext, {})
77
+ if "symbols" in patterns:
78
+ symbols = self._extract_symbols(added_lines, patterns["symbols"])
79
+ for symbol in symbols:
80
+ changes.append(f"changed {symbol}")
81
+
82
+ # Stats
83
+ num_added = len(added_lines)
84
+ num_removed = len(removed_lines)
85
+
86
+ if num_added > 0 or num_removed > 0:
87
+ changes.append(f"+{num_added}/-{num_removed}")
88
+
89
+ return changes
90
+
91
+ def extract_summary(self, raw_diff: str, file_path: str) -> str:
92
+ """Extract one-line summary of changes.
93
+
94
+ Args:
95
+ raw_diff: Raw diff content.
96
+ file_path: Path to file.
97
+
98
+ Returns:
99
+ Summary string.
100
+ """
101
+ changes = self.parse_diff(raw_diff, file_path)
102
+
103
+ if not changes:
104
+ return "Modified file"
105
+
106
+ significant = [
107
+ c for c in changes
108
+ if c not in ["Added new content", "Modified content", "Removed content"]
109
+ and not c.startswith("+")
110
+ ]
111
+
112
+ if significant:
113
+ return significant[0]
114
+
115
+ return changes[0]
116
+
117
+ def enrich_file_diff(self, file_diff: FileDiff) -> FileDiff:
118
+ """Attach deterministic symbols and imports to a file diff."""
119
+ file_ext = self._get_file_extension(file_diff.file_path)
120
+ patterns = self.LANGUAGE_PATTERNS.get(file_ext, {})
121
+ changed_lines = self._changed_lines(file_diff.raw_diff)
122
+ if "symbols" in patterns:
123
+ file_diff.symbols = self._extract_symbols(changed_lines, patterns["symbols"])
124
+ file_diff.imports = self._extract_imports(changed_lines)
125
+ return file_diff
126
+
127
+ @staticmethod
128
+ def _get_file_extension(file_path: str) -> str:
129
+ """Get file extension."""
130
+ if '.' not in file_path:
131
+ return ""
132
+ return '.' + file_path.split('.')[-1]
133
+
134
+ @staticmethod
135
+ def _extract_symbols(lines: list[str], pattern: str) -> list[str]:
136
+ """Extract symbols using regex pattern."""
137
+ symbols = []
138
+
139
+ for line in lines:
140
+ matches = re.findall(pattern, line)
141
+ for match in matches:
142
+ if isinstance(match, tuple):
143
+ symbol = next((m for m in match if m), None)
144
+ else:
145
+ symbol = match
146
+
147
+ if symbol:
148
+ symbols.append(symbol)
149
+
150
+ return list(set(symbols)) # Deduplicate
151
+
152
+ @staticmethod
153
+ def _changed_lines(raw_diff: str) -> list[str]:
154
+ lines = []
155
+ for line in raw_diff.splitlines():
156
+ if line.startswith(("+++", "---")):
157
+ continue
158
+ if line.startswith(("+", "-")):
159
+ lines.append(line[1:])
160
+ return lines
161
+
162
+ @staticmethod
163
+ def _extract_imports(lines: list[str]) -> list[str]:
164
+ imports = []
165
+ for line in lines:
166
+ stripped = line.strip()
167
+ if stripped.startswith(("import ", "from ")):
168
+ imports.append(stripped)
169
+ elif " require(" in stripped:
170
+ imports.append(stripped)
171
+ return sorted(set(imports))
172
+
@@ -0,0 +1,101 @@
1
+ """Summary generation from diffs and rich semantics."""
2
+
3
+ from ..models import FileDiff
4
+ from ..logger import get_logger
5
+ from .diff_parser import DiffParser
6
+ from ..cache import cache_manager
7
+
8
+ logger = get_logger("analysis.summarizer")
9
+
10
+
11
+ class Summarizer:
12
+ """Converts raw diffs and structured symbols into semantic summaries."""
13
+
14
+ def __init__(self):
15
+ """Initialize summarizer."""
16
+ self.diff_parser = DiffParser()
17
+
18
+ def summarize_file_diff(self, file_diff: FileDiff) -> str:
19
+ """Generate summary for a file diff using AST and diff analysis.
20
+
21
+ Args:
22
+ file_diff: FileDiff object.
23
+
24
+ Returns:
25
+ Summary string (target: 20-80 words).
26
+ """
27
+ if file_diff.summary:
28
+ return file_diff.summary
29
+
30
+ # Check cache
31
+ cache_key = file_diff.raw_diff + str(file_diff.extracted_symbols)
32
+ cached = cache_manager.get("summaries", cache_key)
33
+ if cached:
34
+ return cached
35
+
36
+ sym = file_diff.extracted_symbols
37
+ sentences = []
38
+
39
+ if sym.added:
40
+ sentences.append(f"Added {', '.join(sym.added[:3])}{' and more' if len(sym.added)>3 else ''}.")
41
+ if sym.modified:
42
+ sentences.append(f"Refactored {', '.join(sym.modified[:3])}.")
43
+ if sym.removed:
44
+ sentences.append(f"Removed {', '.join(sym.removed[:3])}.")
45
+ if sym.imported:
46
+ sentences.append(f"Introduced dependencies on {', '.join(sym.imported[:3])}.")
47
+
48
+ if not sentences:
49
+ # Fallback
50
+ summary = self.diff_parser.extract_summary(
51
+ file_diff.raw_diff,
52
+ file_diff.file_path
53
+ )
54
+ if len(summary) > 80:
55
+ summary = summary[:77] + "..."
56
+ sentences.append(summary)
57
+
58
+ summary = " ".join(sentences)
59
+ if len(summary) > 120:
60
+ summary = summary[:117] + "..."
61
+
62
+ cache_manager.set("summaries", cache_key, summary)
63
+ return summary
64
+
65
+ def summarize_cluster(self, file_diffs: list[FileDiff]) -> str:
66
+ """Generate summary for a cluster of files."""
67
+ if not file_diffs:
68
+ return "No changes"
69
+
70
+ summaries = [self.summarize_file_diff(diff) for diff in file_diffs]
71
+ unique_summaries = list(dict.fromkeys(summaries))
72
+
73
+ if len(unique_summaries) == 1:
74
+ return unique_summaries[0]
75
+
76
+ combined = " ".join(unique_summaries[:3])
77
+ if len(combined) > 120:
78
+ combined = combined[:117] + "..."
79
+
80
+ return combined
81
+
82
+ def extract_keywords(self, file_diff: FileDiff) -> list[str]:
83
+ """Extract keywords from a file diff."""
84
+ keywords = []
85
+ sym = file_diff.extracted_symbols
86
+ keywords.extend(sym.added)
87
+ keywords.extend(sym.modified)
88
+ keywords.extend(sym.removed)
89
+ keywords.extend(sym.imported)
90
+ keywords.extend(sym.classes)
91
+ keywords.extend(sym.functions)
92
+
93
+ if not keywords:
94
+ # Fallback
95
+ path_parts = file_diff.file_path.lower().split('/')
96
+ for part in path_parts:
97
+ clean = part.replace('.', '').replace('-', '_')
98
+ if clean and len(clean) > 2 and clean not in ['src', 'lib', 'utils', 'test']:
99
+ keywords.append(clean)
100
+
101
+ return list(set(keywords))
@@ -0,0 +1,88 @@
1
+ """Symbol extraction bridging diffs and AST."""
2
+
3
+ from typing import List, Dict, Set
4
+ from ..models import FileDiff, SymbolExtraction
5
+ from .ast_parser import ASTParser
6
+ from .diff_parser import DiffParser
7
+ from ..logger import get_logger
8
+
9
+ logger = get_logger("analysis.symbol_extractor")
10
+
11
+
12
+ class SymbolExtractor:
13
+ """Extracts symbols from changed files."""
14
+
15
+ def __init__(self):
16
+ self.diff_parser = DiffParser()
17
+
18
+ def extract_symbols(self, file_diff: FileDiff) -> SymbolExtraction:
19
+ """Extract rich symbol information from a file diff.
20
+
21
+ Args:
22
+ file_diff: FileDiff object containing raw diff.
23
+
24
+ Returns:
25
+ SymbolExtraction with categorized symbols.
26
+ """
27
+ extraction = SymbolExtraction()
28
+
29
+ if not file_diff.raw_diff.strip():
30
+ return extraction
31
+
32
+ file_path = file_diff.file_path
33
+ ext = self.diff_parser._get_file_extension(file_path).lstrip(".")
34
+ if ext in ["py"]:
35
+ lang = "python"
36
+ elif ext in ["js", "jsx"]:
37
+ lang = "javascript"
38
+ elif ext in ["ts", "tsx"]:
39
+ lang = "typescript"
40
+ else:
41
+ lang = ext
42
+
43
+ # Try to parse full added content for AST if available
44
+ # But we only have diff. So we can extract added/removed lines
45
+ added_lines = [l[1:] for l in file_diff.raw_diff.split('\n') if l.startswith('+') and not l.startswith('+++')]
46
+ removed_lines = [l[1:] for l in file_diff.raw_diff.split('\n') if l.startswith('-') and not l.startswith('---')]
47
+
48
+ added_content = "\n".join(added_lines)
49
+ removed_content = "\n".join(removed_lines)
50
+
51
+ ast_parser = ASTParser(lang)
52
+
53
+ # Parse added content
54
+ if added_content:
55
+ extraction.classes.extend(ast_parser.extract_classes(added_content))
56
+ extraction.functions.extend(ast_parser.extract_functions(added_content))
57
+ extraction.imported.extend(ast_parser.extract_imports(added_content))
58
+
59
+ # Use diff parser for standard symbols as a fallback
60
+ patterns = self.diff_parser.LANGUAGE_PATTERNS.get("." + ext, {})
61
+ if "symbols" in patterns:
62
+ symbols = self.diff_parser._extract_symbols(added_lines, patterns["symbols"])
63
+ extraction.added.extend(symbols)
64
+
65
+ imports = self.diff_parser._extract_imports(added_lines)
66
+ extraction.imported.extend(imports)
67
+
68
+ # Parse removed content
69
+ if removed_content:
70
+ patterns = self.diff_parser.LANGUAGE_PATTERNS.get("." + ext, {})
71
+ if "symbols" in patterns:
72
+ symbols = self.diff_parser._extract_symbols(removed_lines, patterns["symbols"])
73
+ extraction.removed.extend(symbols)
74
+
75
+ # Deduplicate
76
+ extraction.classes = list(set(extraction.classes))
77
+ extraction.functions = list(set(extraction.functions))
78
+ extraction.imported = list(set(extraction.imported))
79
+ extraction.added = list(set(extraction.added))
80
+ extraction.removed = list(set(extraction.removed))
81
+
82
+ # Determine modified (present in both added and removed)
83
+ modified = set(extraction.added).intersection(set(extraction.removed))
84
+ extraction.modified = list(modified)
85
+ extraction.added = list(set(extraction.added) - modified)
86
+ extraction.removed = list(set(extraction.removed) - modified)
87
+
88
+ return extraction
@@ -0,0 +1,88 @@
1
+ """Benchmarking for Smart Commit pipeline."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .git import RepositoryScanner
7
+ from .analysis import DiffParser, Summarizer
8
+ from .analysis.symbol_extractor import SymbolExtractor
9
+ from .analysis.dependency_graph import DependencyGraph
10
+ from .clustering import SemanticClustering
11
+
12
+ try:
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ except ImportError:
16
+ Console = None
17
+ Table = None
18
+
19
+ class BenchmarkHarness:
20
+ """Measures performance of the semantic pipeline."""
21
+
22
+ def __init__(self, repo_path: Path):
23
+ self.repo_path = repo_path
24
+ self.console = Console() if Console else None
25
+
26
+ def run(self):
27
+ """Execute the benchmark."""
28
+ print(f"Starting benchmark on {self.repo_path}...")
29
+ results = []
30
+
31
+ # 1. Scanning
32
+ start = time.time()
33
+ snapshot = RepositoryScanner(self.repo_path).scan()
34
+ results.append(("Scanning", time.time() - start))
35
+
36
+ num_files = snapshot.total_files_changed
37
+ if num_files == 0:
38
+ print("No files changed in repo. Cannot benchmark.")
39
+ return
40
+
41
+ # 2. Parsing & Symbols
42
+ start = time.time()
43
+ parser = DiffParser()
44
+ extractor = SymbolExtractor()
45
+ for file_diff in snapshot.all_files:
46
+ parser.enrich_file_diff(file_diff)
47
+ file_diff.extracted_symbols = extractor.extract_symbols(file_diff)
48
+ results.append(("AST & Symbols", time.time() - start))
49
+
50
+ # 3. Summaries
51
+ start = time.time()
52
+ summarizer = Summarizer()
53
+ for file_diff in snapshot.all_files:
54
+ file_diff.summary = summarizer.summarize_file_diff(file_diff)
55
+ results.append(("Summarization", time.time() - start))
56
+
57
+ # 4. Dependency Graph
58
+ start = time.time()
59
+ dep_graph = DependencyGraph(self.repo_path)
60
+ results.append(("Dependency Graph", time.time() - start))
61
+
62
+ # 5. Clustering (includes Embeddings)
63
+ start = time.time()
64
+ clustering = SemanticClustering()
65
+ clustering.dep_graph = dep_graph
66
+ clusters = clustering.cluster_files(snapshot.all_files, repo_path=self.repo_path)
67
+ results.append(("Clustering & Embeddings", time.time() - start))
68
+
69
+ total_time = sum(t for _, t in results)
70
+
71
+ if self.console and Table:
72
+ table = Table(title=f"Benchmark Results ({num_files} files)")
73
+ table.add_column("Stage", style="cyan")
74
+ table.add_column("Time (s)", justify="right", style="green")
75
+
76
+ for stage, t in results:
77
+ table.add_row(stage, f"{t:.3f}")
78
+ table.add_row("Total", f"{total_time:.3f}", style="bold yellow")
79
+
80
+ self.console.print()
81
+ self.console.print(table)
82
+ else:
83
+ print(f"\nBenchmark Results ({num_files} files)")
84
+ for stage, t in results:
85
+ print(f"{stage}: {t:.3f}s")
86
+ print(f"Total: {total_time:.3f}s")
87
+
88
+ print(f"\nGenerated {len(clusters)} clusters.")
smart_commit/cache.py ADDED
@@ -0,0 +1,65 @@
1
+ """Disk-based caching for AST, summaries, and embeddings."""
2
+
3
+ import json
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import Any, Optional, Dict
7
+
8
+ from .logger import get_logger
9
+
10
+ logger = get_logger("cache")
11
+
12
+
13
+ class CacheManager:
14
+ """Manages disk-based caching for expensive operations."""
15
+
16
+ def __init__(self, cache_dir: str = ".smart_commit_cache"):
17
+ self.cache_dir = Path(cache_dir)
18
+ self.cache_file = self.cache_dir / "cache.json"
19
+ self._cache: Dict[str, Any] = {}
20
+ self._load_cache()
21
+
22
+ def _load_cache(self):
23
+ """Load cache from disk."""
24
+ if not self.cache_file.exists():
25
+ return
26
+ try:
27
+ with open(self.cache_file, "r", encoding="utf-8") as f:
28
+ self._cache = json.load(f)
29
+ except Exception as e:
30
+ logger.debug(f"Failed to load cache: {e}")
31
+ self._cache = {}
32
+
33
+ def _save_cache(self):
34
+ """Save cache to disk."""
35
+ try:
36
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
37
+ with open(self.cache_file, "w", encoding="utf-8") as f:
38
+ json.dump(self._cache, f)
39
+ except Exception as e:
40
+ logger.debug(f"Failed to save cache: {e}")
41
+
42
+ def _generate_key(self, namespace: str, text: str) -> str:
43
+ """Generate a stable cache key."""
44
+ text_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
45
+ return f"{namespace}_{text_hash}"
46
+
47
+ def get(self, namespace: str, text: str) -> Optional[Any]:
48
+ """Retrieve a value from the cache."""
49
+ key = self._generate_key(namespace, text)
50
+ return self._cache.get(key)
51
+
52
+ def set(self, namespace: str, text: str, value: Any):
53
+ """Set a value in the cache."""
54
+ key = self._generate_key(namespace, text)
55
+ self._cache[key] = value
56
+ self._save_cache()
57
+
58
+ def clear(self):
59
+ """Clear the cache."""
60
+ self._cache = {}
61
+ if self.cache_file.exists():
62
+ self.cache_file.unlink()
63
+
64
+ # Global cache instance
65
+ cache_manager = CacheManager()