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,159 @@
1
+ """Similarity graph for commit clustering."""
2
+
3
+ from typing import List, Dict, Optional
4
+ import numpy as np
5
+ from dataclasses import dataclass
6
+
7
+ from ..models import FileDiff
8
+ from ..constants import SIMILARITY_WEIGHTS
9
+ from ..logger import get_logger
10
+ from .generator import EmbeddingsGenerator
11
+ from ..analysis.dependency_graph import DependencyGraph
12
+
13
+ logger = get_logger("embeddings.similarity")
14
+
15
+
16
+ @dataclass
17
+ class SimilarityScore:
18
+ """Structured breakdown of similarity between two files."""
19
+ semantic: float = 0.0
20
+ dependency: float = 0.0
21
+ imports: float = 0.0
22
+ symbols: float = 0.0
23
+ directory: float = 0.0
24
+ filename: float = 0.0
25
+ total: float = 0.0
26
+ details: List[str] = None
27
+
28
+ def __post_init__(self):
29
+ if self.details is None:
30
+ self.details = []
31
+
32
+
33
+ class SimilarityGraph:
34
+ """Builds a weighted similarity graph between files."""
35
+
36
+ def __init__(self, embeddings_gen: EmbeddingsGenerator, dep_graph: Optional[DependencyGraph] = None, weights: Optional[Dict[str, float]] = None):
37
+ """Initialize similarity graph."""
38
+ self.embeddings_gen = embeddings_gen
39
+ self.dep_graph = dep_graph
40
+ self.weights = weights or SIMILARITY_WEIGHTS.copy()
41
+
42
+ # Adjust weights if dependency graph is provided
43
+ if "dependency" not in self.weights:
44
+ self.weights["dependency"] = 0.15
45
+ # Reduce others proportionally to sum to 1.0,
46
+ # but simpler to just normalize at the end.
47
+ total_weight = sum(self.weights.values())
48
+ for k in self.weights:
49
+ self.weights[k] /= total_weight
50
+
51
+ def build_graph(self, file_diffs: List[FileDiff]) -> np.ndarray:
52
+ """Build similarity matrix for files."""
53
+ n = len(file_diffs)
54
+ matrix = np.zeros((n, n))
55
+
56
+ for i in range(n):
57
+ matrix[i][i] = 1.0
58
+ for j in range(i + 1, n):
59
+ score = self.compute_file_similarity(file_diffs[i], file_diffs[j])
60
+ matrix[i][j] = score.total
61
+ matrix[j][i] = score.total
62
+
63
+ return matrix
64
+
65
+ def compute_file_similarity(self, file1: FileDiff, file2: FileDiff) -> SimilarityScore:
66
+ """Compute structured weighted similarity between two files."""
67
+ score = SimilarityScore()
68
+
69
+ # Handle renames logically - if it's a rename of the same logical file, score high
70
+ if file1.is_renamed and file1.old_file_path == file2.file_path:
71
+ score.total = 1.0
72
+ score.details.append("Renamed file match")
73
+ return score
74
+ if file2.is_renamed and file2.old_file_path == file1.file_path:
75
+ score.total = 1.0
76
+ score.details.append("Renamed file match")
77
+ return score
78
+
79
+ # 1. Semantic similarity
80
+ emb1 = self.embeddings_gen.generate_file_embedding(file1)
81
+ emb2 = self.embeddings_gen.generate_file_embedding(file2)
82
+ if emb1 and emb2:
83
+ score.semantic = self.embeddings_gen.compute_similarity(emb1, emb2)
84
+ if score.semantic > 0.6:
85
+ score.details.append("High semantic similarity")
86
+
87
+ # 2. Dependency Graph distance
88
+ if self.dep_graph:
89
+ dist = self.dep_graph.get_distance(file1.file_path, file2.file_path)
90
+ if dist == 1.0:
91
+ score.dependency = 1.0
92
+ score.details.append("Direct dependency")
93
+ elif dist == 2.0:
94
+ score.dependency = 0.5
95
+ score.details.append("Close dependency")
96
+
97
+ # 3. Directory similarity
98
+ dir1 = self._get_directory(file1.file_path)
99
+ dir2 = self._get_directory(file2.file_path)
100
+
101
+ if dir1 == dir2 and dir1:
102
+ score.directory = 1.0
103
+ score.details.append("Same subsystem")
104
+ elif dir1 and dir2 and (dir1.startswith(dir2) or dir2.startswith(dir1)):
105
+ score.directory = 0.5
106
+ score.details.append("Related subsystem")
107
+
108
+ # 4. Import & Symbol overlap
109
+ sym1 = file1.extracted_symbols
110
+ sym2 = file2.extracted_symbols
111
+
112
+ imports1 = set(sym1.imported)
113
+ imports2 = set(sym2.imported)
114
+ if imports1 and imports2:
115
+ overlap = len(imports1 & imports2)
116
+ total = len(imports1 | imports2)
117
+ score.imports = overlap / total
118
+ if score.imports > 0.2:
119
+ score.details.append("Shared imports")
120
+
121
+ all_sym1 = set(sym1.added + sym1.modified + sym1.removed + sym1.classes + sym1.functions)
122
+ all_sym2 = set(sym2.added + sym2.modified + sym2.removed + sym2.classes + sym2.functions)
123
+ if all_sym1 and all_sym2:
124
+ overlap = len(all_sym1 & all_sym2)
125
+ total = len(all_sym1 | all_sym2)
126
+ score.symbols = overlap / total
127
+ if score.symbols > 0.1:
128
+ score.details.append("Shared symbols")
129
+
130
+ # 5. Filename similarity
131
+ name1 = file1.file_path.split('/')[-1].lower()
132
+ name2 = file2.file_path.split('/')[-1].lower()
133
+ if name1 == name2:
134
+ score.filename = 1.0
135
+ elif name1 in name2 or name2 in name1:
136
+ score.filename = 0.5
137
+
138
+ # Compute total
139
+ total = (
140
+ score.semantic * self.weights.get("semantic", 0.45) +
141
+ score.dependency * self.weights.get("dependency", 0.15) +
142
+ score.directory * self.weights.get("directory", 0.20) +
143
+ score.imports * self.weights.get("imports", 0.15) +
144
+ score.symbols * self.weights.get("symbols", 0.15) +
145
+ score.filename * self.weights.get("filename", 0.05)
146
+ )
147
+
148
+ # Re-normalize if weights sum > 1.0
149
+ weight_sum = sum(self.weights.values())
150
+ if weight_sum > 0:
151
+ total /= weight_sum
152
+
153
+ score.total = min(1.0, total)
154
+ return score
155
+
156
+ @staticmethod
157
+ def _get_directory(file_path: str) -> str:
158
+ parts = file_path.split('/')
159
+ return '/'.join(parts[:-1]) if len(parts) > 1 else ""
@@ -0,0 +1,108 @@
1
+ """Embeddings generator for semantic similarity."""
2
+
3
+ from typing import List, Optional
4
+ import numpy as np
5
+ from .models import FileDiff, CommitCluster
6
+
7
+
8
+ class EmbeddingsGenerator:
9
+ """Generates embeddings for semantic similarity."""
10
+
11
+ def __init__(self, use_local: bool = True):
12
+ """Initialize embeddings generator.
13
+
14
+ Args:
15
+ use_local: Use local sentence transformers (True) or API-based (False).
16
+ """
17
+ self.use_local = use_local
18
+ self.model = None
19
+
20
+ if use_local:
21
+ self._init_local_model()
22
+
23
+ def _init_local_model(self):
24
+ """Initialize local sentence transformer model."""
25
+ try:
26
+ from sentence_transformers import SentenceTransformer
27
+ self.model = SentenceTransformer('all-MiniLM-L6-v2')
28
+ except Exception as e:
29
+ raise RuntimeError(f"Failed to load embedding model: {e}")
30
+
31
+ def generate_embedding(self, text: str) -> Optional[List[float]]:
32
+ """Generate embedding for a text string.
33
+
34
+ Args:
35
+ text: Text to embed.
36
+
37
+ Returns:
38
+ Embedding vector as list of floats.
39
+ """
40
+ if not text or not self.model:
41
+ return None
42
+
43
+ try:
44
+ embeddings = self.model.encode(text, convert_to_numpy=True)
45
+ return embeddings.tolist()
46
+ except Exception:
47
+ return None
48
+
49
+ def generate_file_embedding(self, file_diff: FileDiff) -> Optional[List[float]]:
50
+ """Generate embedding for a file diff.
51
+
52
+ Args:
53
+ file_diff: FileDiff object.
54
+
55
+ Returns:
56
+ Embedding vector.
57
+ """
58
+ # Combine file path and summary for embedding
59
+ text_parts = [
60
+ file_diff.file_path,
61
+ file_diff.summary or ""
62
+ ]
63
+
64
+ text = " ".join(text_parts).strip()
65
+
66
+ return self.generate_embedding(text)
67
+
68
+ def compute_similarity(self, embedding1: List[float], embedding2: List[float]) -> float:
69
+ """Compute cosine similarity between two embeddings.
70
+
71
+ Args:
72
+ embedding1: First embedding vector.
73
+ embedding2: Second embedding vector.
74
+
75
+ Returns:
76
+ Similarity score (0-1).
77
+ """
78
+ if not embedding1 or not embedding2:
79
+ return 0.0
80
+
81
+ arr1 = np.array(embedding1)
82
+ arr2 = np.array(embedding2)
83
+
84
+ # Compute cosine similarity
85
+ dot_product = np.dot(arr1, arr2)
86
+ norm1 = np.linalg.norm(arr1)
87
+ norm2 = np.linalg.norm(arr2)
88
+
89
+ if norm1 == 0 or norm2 == 0:
90
+ return 0.0
91
+
92
+ return float(dot_product / (norm1 * norm2))
93
+
94
+ def compute_cluster_similarity(self, cluster1: CommitCluster, cluster2: CommitCluster) -> float:
95
+ """Compute similarity between two clusters.
96
+
97
+ Args:
98
+ cluster1: First cluster.
99
+ cluster2: Second cluster.
100
+
101
+ Returns:
102
+ Similarity score (0-1).
103
+ """
104
+ if not cluster1.embedding or not cluster2.embedding:
105
+ return 0.0
106
+
107
+ return self.compute_similarity(cluster1.embedding, cluster2.embedding)
108
+
@@ -0,0 +1,102 @@
1
+ """Custom exceptions for Smart Commit."""
2
+
3
+
4
+ class SmartCommitException(Exception):
5
+ """Base exception for Smart Commit."""
6
+ pass
7
+
8
+
9
+ class GitException(SmartCommitException):
10
+ """Git-related errors."""
11
+ pass
12
+
13
+
14
+ class InvalidRepositoryError(GitException):
15
+ """Raised when repository is invalid or not found."""
16
+ pass
17
+
18
+
19
+ class NoChangesError(GitException):
20
+ """Raised when no changes are found."""
21
+ pass
22
+
23
+
24
+ class StagingError(GitException):
25
+ """Raised when staging files fails."""
26
+ pass
27
+
28
+
29
+ class CommitError(GitException):
30
+ """Raised when commit creation fails."""
31
+ pass
32
+
33
+
34
+ class ConfigurationError(SmartCommitException):
35
+ """Configuration-related errors."""
36
+ pass
37
+
38
+
39
+ class InvalidConfigurationError(ConfigurationError):
40
+ """Raised when configuration is invalid."""
41
+ pass
42
+
43
+
44
+ class LLMException(SmartCommitException):
45
+ """LLM-related errors."""
46
+ pass
47
+
48
+
49
+ class LLMTimeoutError(LLMException):
50
+ """Raised when LLM request times out."""
51
+ pass
52
+
53
+
54
+ class LLMRateLimitError(LLMException):
55
+ """Raised when LLM rate limit is exceeded."""
56
+ pass
57
+
58
+
59
+ class AnalysisException(SmartCommitException):
60
+ """Analysis-related errors."""
61
+ pass
62
+
63
+
64
+ class DiffParsingError(AnalysisException):
65
+ """Raised when diff parsing fails."""
66
+ pass
67
+
68
+
69
+ class ClusteringException(SmartCommitException):
70
+ """Clustering-related errors."""
71
+ pass
72
+
73
+
74
+ class PreviewException(SmartCommitException):
75
+ """Preview UI errors."""
76
+ pass
77
+
78
+
79
+ class PluginException(SmartCommitException):
80
+ """Plugin system errors."""
81
+ pass
82
+
83
+
84
+ class PluginLoadError(PluginException):
85
+ """Raised when plugin fails to load."""
86
+ pass
87
+
88
+
89
+ class UndoException(SmartCommitException):
90
+ """Undo operation errors."""
91
+ pass
92
+
93
+
94
+ class SafetyCheckFailed(SmartCommitException):
95
+ """Raised when safety check fails."""
96
+ pass
97
+
98
+
99
+ class UserCancelled(SmartCommitException):
100
+ """Raised when user cancels operation."""
101
+ pass
102
+
@@ -0,0 +1,6 @@
1
+ """Execution package."""
2
+
3
+ from .committer import Committer
4
+
5
+ __all__ = ["Committer"]
6
+
@@ -0,0 +1,102 @@
1
+ """Commit execution and session management."""
2
+
3
+ from ..models import CommitCluster, SessionInfo
4
+ from ..git import GitService
5
+ from ..utils import generate_session_id, get_timestamp
6
+ from ..logger import get_logger
7
+
8
+ logger = get_logger("execution.committer")
9
+
10
+
11
+ class Committer:
12
+ """Executes commits and manages sessions."""
13
+
14
+ def __init__(self, git_service: GitService):
15
+ """Initialize committer.
16
+
17
+ Args:
18
+ git_service: GitService instance.
19
+ """
20
+ self.git_service = git_service
21
+ self.session = SessionInfo(
22
+ session_id=generate_session_id(),
23
+ timestamp=get_timestamp(),
24
+ branch=git_service.current_branch(),
25
+ repository_path=str(git_service.repo_path)
26
+ )
27
+
28
+ def commit_cluster(self, cluster: CommitCluster) -> str:
29
+ """Commit a cluster of files.
30
+
31
+ Args:
32
+ cluster: CommitCluster to commit.
33
+
34
+ Returns:
35
+ Commit hash.
36
+ """
37
+ file_paths = [f.file_path for f in cluster.files]
38
+
39
+ # Stage files
40
+ logger.info(f"Staging {len(file_paths)} file(s)...")
41
+ self.git_service.stage_files(file_paths)
42
+
43
+ # Create commit
44
+ message = cluster.message or "Automatic commit"
45
+ logger.info(f"Creating commit: {message}")
46
+ commit_hash = self.git_service.commit(message)
47
+
48
+ # Track in session
49
+ self.session.commit_hashes.append(commit_hash)
50
+
51
+ return commit_hash
52
+
53
+ def commit_clusters(self, clusters: list[CommitCluster]) -> list[str]:
54
+ """Commit multiple clusters sequentially.
55
+
56
+ Args:
57
+ clusters: List of CommitCluster objects.
58
+
59
+ Returns:
60
+ List of commit hashes.
61
+ """
62
+ commit_hashes = []
63
+
64
+ try:
65
+ for idx, cluster in enumerate(clusters, 1):
66
+ logger.info(f"Processing cluster {idx}/{len(clusters)}...")
67
+ commit_hash = self.commit_cluster(cluster)
68
+ commit_hashes.append(commit_hash)
69
+ except Exception as exc:
70
+ if commit_hashes:
71
+ logger.error("Commit failed; rolling back %s created commit(s)", len(commit_hashes))
72
+ self.git_service.reset_soft(len(commit_hashes))
73
+ raise exc
74
+
75
+ if commit_hashes:
76
+ self.git_service.save_session(self.session)
77
+ logger.info(f"Session saved: {self.session.session_id}")
78
+
79
+ return commit_hashes
80
+
81
+ def dry_run(self, clusters: list[CommitCluster]) -> list[dict]:
82
+ """Simulate commits without actually creating them.
83
+
84
+ Args:
85
+ clusters: List of CommitCluster objects.
86
+
87
+ Returns:
88
+ List of simulated commit info.
89
+ """
90
+ simulated = []
91
+
92
+ for idx, cluster in enumerate(clusters, 1):
93
+ simulated.append({
94
+ "number": idx,
95
+ "message": cluster.message or "Automatic commit",
96
+ "files": len(cluster.files),
97
+ "commit_type": cluster.commit_type.value if cluster.commit_type else "chore"
98
+ })
99
+
100
+ logger.info(f"Dry run: would create {len(simulated)} commit(s)")
101
+ return simulated
102
+
@@ -0,0 +1,8 @@
1
+ """Git operations package."""
2
+
3
+ from .service import GitService
4
+ from .scanner import RepositoryScanner
5
+ from .safety import SafetyChecker
6
+
7
+ __all__ = ["GitService", "RepositoryScanner", "SafetyChecker"]
8
+
@@ -0,0 +1,75 @@
1
+ """Safety checks before commit execution."""
2
+
3
+ from typing import List
4
+ from pathlib import Path
5
+
6
+ from ..logger import get_logger
7
+ from ..exceptions import SafetyCheckFailed
8
+
9
+ logger = get_logger("git.safety")
10
+
11
+
12
+ class SafetyChecker:
13
+ """Performs safety checks before execution."""
14
+
15
+ @staticmethod
16
+ def check_branch_protected(repo_path: Path, branch: str) -> bool:
17
+ """Check if branch appears to be protected.
18
+
19
+ Args:
20
+ repo_path: Path to repository.
21
+ branch: Branch name.
22
+
23
+ Returns:
24
+ True if branch is likely protected.
25
+ """
26
+ protected_branches = {'main', 'master', 'production', 'release'}
27
+ return branch.lower() in protected_branches
28
+
29
+ @staticmethod
30
+ def check_uncommitted_changes(changed_files: int) -> bool:
31
+ """Check if there are too many changes.
32
+
33
+ Args:
34
+ changed_files: Number of changed files.
35
+
36
+ Returns:
37
+ True if change count is reasonable.
38
+ """
39
+ # Warn if more than 100 files
40
+ return changed_files <= 100
41
+
42
+ @staticmethod
43
+ def check_commit_messages(messages: List[str]) -> bool:
44
+ """Validate commit messages.
45
+
46
+ Args:
47
+ messages: List of commit messages.
48
+
49
+ Returns:
50
+ True if all messages are valid.
51
+ """
52
+ for msg in messages:
53
+ if not msg or len(msg.strip()) == 0:
54
+ return False
55
+ if len(msg) > 100:
56
+ return False
57
+
58
+ return True
59
+
60
+ @staticmethod
61
+ def check_file_paths(file_paths: List[str]) -> bool:
62
+ """Check if file paths are valid.
63
+
64
+ Args:
65
+ file_paths: List of file paths.
66
+
67
+ Returns:
68
+ True if all paths are valid.
69
+ """
70
+ for path in file_paths:
71
+ if '..' in path or path.startswith('/'):
72
+ return False
73
+
74
+ return True
75
+