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,91 @@
1
+ """Commit execution and session management."""
2
+
3
+ from typing import List
4
+ from pathlib import Path
5
+
6
+ from .models import CommitCluster, SessionInfo, FileDiff
7
+ from .git_service import GitService
8
+ from .utils import generate_session_id, get_timestamp
9
+
10
+
11
+ class Committer:
12
+ """Handles file staging, commit execution, and session management."""
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
+ """Stage files and create a commit for a cluster.
30
+
31
+ Args:
32
+ cluster: CommitCluster to commit.
33
+
34
+ Returns:
35
+ Commit hash.
36
+ """
37
+ # Extract file paths
38
+ file_paths = [f.file_path for f in cluster.files]
39
+
40
+ # Stage files
41
+ self.git_service.stage_files(file_paths)
42
+
43
+ # Create commit
44
+ message = cluster.message or "Automatic commit"
45
+ commit_hash = self.git_service.commit(message)
46
+
47
+ # Track in session
48
+ self.session.commit_hashes.append(commit_hash)
49
+
50
+ return commit_hash
51
+
52
+ def commit_clusters(self, clusters: List[CommitCluster]) -> List[str]:
53
+ """Commit multiple clusters in sequence.
54
+
55
+ Args:
56
+ clusters: List of CommitCluster objects.
57
+
58
+ Returns:
59
+ List of commit hashes.
60
+ """
61
+ commit_hashes = []
62
+
63
+ for cluster in clusters:
64
+ try:
65
+ commit_hash = self.commit_cluster(cluster)
66
+ commit_hashes.append(commit_hash)
67
+ except Exception as e:
68
+ # Log error but continue with next cluster
69
+ print(f"Error committing cluster: {e}")
70
+
71
+ # Save session for undo support
72
+ self.git_service.save_session(self.session)
73
+
74
+ return commit_hashes
75
+
76
+ def stage_files(self, file_paths: List[str]) -> None:
77
+ """Stage specific files.
78
+
79
+ Args:
80
+ file_paths: List of file paths to stage.
81
+ """
82
+ self.git_service.stage_files(file_paths)
83
+
84
+ def unstage_files(self, file_paths: List[str]) -> None:
85
+ """Unstage specific files.
86
+
87
+ Args:
88
+ file_paths: List of file paths to unstage.
89
+ """
90
+ self.git_service.unstage_files(file_paths)
91
+
smart_commit/config.py ADDED
@@ -0,0 +1,88 @@
1
+ """Configuration management for Smart Commit."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import os
7
+
8
+ try:
9
+ from dotenv import load_dotenv
10
+ except ImportError: # pragma: no cover - optional dependency fallback
11
+ def load_dotenv() -> None:
12
+ return None
13
+
14
+
15
+ load_dotenv()
16
+
17
+
18
+ @dataclass
19
+ class Config:
20
+ """Smart Commit configuration."""
21
+
22
+ provider: str = "openrouter"
23
+ model: str = "anthropic/claude-sonnet"
24
+ api_key: Optional[str] = None
25
+ embedding_model: str = "all-MiniLM-L6-v2"
26
+ max_files_per_commit: int = 8
27
+ clustering_method: str = "agglomerative"
28
+ similarity_threshold: float = 0.5
29
+ interactive: bool = True
30
+ auto_push: bool = False
31
+ conventional_commits: bool = True
32
+ use_local_embeddings: bool = True
33
+ max_diff_size: int = 100000
34
+ session_history_dir: str = ".smart_commit"
35
+
36
+ @staticmethod
37
+ def load_from_file(config_path: Path) -> "Config":
38
+ """Load configuration from YAML."""
39
+ if not config_path.exists():
40
+ return Config.from_env()
41
+
42
+ try:
43
+ import yaml
44
+ except ImportError:
45
+ return Config.from_env()
46
+
47
+ with config_path.open(encoding="utf-8") as file:
48
+ data = yaml.safe_load(file) or {}
49
+ env_config = Config.from_env()
50
+ merged = asdict(env_config)
51
+ merged.update(data)
52
+ return Config(**merged)
53
+
54
+ @staticmethod
55
+ def from_env() -> "Config":
56
+ """Load configuration from environment variables."""
57
+ return Config(
58
+ provider=os.getenv("SMART_COMMIT_PROVIDER", "openrouter"),
59
+ model=os.getenv("SMART_COMMIT_MODEL", "anthropic/claude-sonnet"),
60
+ api_key=os.getenv("SMART_COMMIT_API_KEY"),
61
+ embedding_model=os.getenv("SMART_COMMIT_EMBEDDING_MODEL", "all-MiniLM-L6-v2"),
62
+ max_files_per_commit=int(os.getenv("SMART_COMMIT_MAX_FILES_PER_COMMIT", "8")),
63
+ clustering_method=os.getenv("SMART_COMMIT_CLUSTERING_METHOD", "agglomerative"),
64
+ similarity_threshold=float(os.getenv("SMART_COMMIT_SIMILARITY_THRESHOLD", "0.5")),
65
+ interactive=os.getenv("SMART_COMMIT_INTERACTIVE", "true").lower() != "false",
66
+ auto_push=os.getenv("SMART_COMMIT_AUTO_PUSH", "false").lower() == "true",
67
+ conventional_commits=os.getenv("SMART_COMMIT_CONVENTIONAL_COMMITS", "true").lower()
68
+ != "false",
69
+ use_local_embeddings=os.getenv("SMART_COMMIT_USE_LOCAL_EMBEDDINGS", "true").lower()
70
+ != "false",
71
+ )
72
+
73
+ @staticmethod
74
+ def load() -> "Config":
75
+ """Load configuration from file, then environment defaults."""
76
+ return Config.load_from_file(Path.home() / ".smart_commit" / "config.yaml")
77
+
78
+ def save_to_file(self, config_path: Path) -> None:
79
+ """Save configuration as YAML."""
80
+ config_path.parent.mkdir(parents=True, exist_ok=True)
81
+ try:
82
+ import yaml
83
+
84
+ with config_path.open("w", encoding="utf-8") as file:
85
+ yaml.safe_dump(asdict(self), file, default_flow_style=False)
86
+ except ImportError:
87
+ lines = [f"{key}: {value}" for key, value in asdict(self).items()]
88
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -0,0 +1,92 @@
1
+ """Constants for Smart Commit."""
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+
6
+ # Version
7
+ VERSION = "0.2.0"
8
+
9
+ # Default configuration
10
+ DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2"
11
+ DEFAULT_LLM_PROVIDER = "openrouter"
12
+ DEFAULT_LLM_MODEL = "anthropic/claude-sonnet"
13
+ DEFAULT_MAX_FILES_PER_COMMIT = 8
14
+ DEFAULT_SIMILARITY_THRESHOLD = 0.5
15
+ DEFAULT_MAX_DIFF_SIZE = 100000 # chars
16
+
17
+ # Configuration file locations
18
+ CONFIG_DIR = Path.home() / ".smart_commit"
19
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
20
+ SESSION_DIR = CONFIG_DIR / "sessions"
21
+ PLUGINS_DIR = CONFIG_DIR / "plugins"
22
+
23
+ # Weight factors for similarity graph
24
+ SIMILARITY_WEIGHTS = {
25
+ "semantic": 0.45, # Embeddings
26
+ "directory": 0.20, # Same folder
27
+ "imports": 0.15, # Import overlap
28
+ "symbols": 0.15, # Symbol overlap
29
+ "filename": 0.05 # Filename similarity
30
+ }
31
+
32
+ # Max retries for LLM
33
+ MAX_LLM_RETRIES = 3
34
+ LLM_TIMEOUT = 30 # seconds
35
+
36
+ # File size limits
37
+ MAX_FILE_DIFF_SIZE = 500000 # 500KB per file
38
+ MAX_TOTAL_DIFF_SIZE = 5000000 # 5MB total
39
+
40
+ # Clustering
41
+ DEFAULT_CLUSTERING_METHOD = "agglomerative"
42
+ MIN_CLUSTER_SIZE = 1
43
+ MAX_CLUSTER_SIZE = 10
44
+
45
+ # UI
46
+ PROGRESS_BAR_WIDTH = 40
47
+ MAX_FILES_IN_PREVIEW = 5
48
+ PREVIEW_WRAP_LENGTH = 80
49
+
50
+ # Commit message
51
+ MAX_COMMIT_TITLE_LENGTH = 72
52
+ MAX_COMMIT_BODY_LENGTH = 100
53
+
54
+ # Session
55
+ SESSION_HISTORY_MAX = 50
56
+
57
+
58
+ class CommitAction(str, Enum):
59
+ """User actions in preview."""
60
+ ACCEPT = "a"
61
+ EDIT = "e"
62
+ SPLIT = "s"
63
+ MERGE = "m"
64
+ DISCARD = "d"
65
+ SKIP = "k"
66
+ QUIT = "q"
67
+
68
+
69
+ class FileStatus(str, Enum):
70
+ """Git file status."""
71
+ ADDED = "A"
72
+ MODIFIED = "M"
73
+ DELETED = "D"
74
+ RENAMED = "R"
75
+ COPIED = "C"
76
+ UNTRACKED = "U"
77
+
78
+
79
+ class ClusteringMethod(str, Enum):
80
+ """Clustering algorithm options."""
81
+ AGGLOMERATIVE = "agglomerative"
82
+ HDBSCAN = "hdbscan"
83
+ HEURISTIC = "heuristic"
84
+
85
+
86
+ class ConfidenceLevel(str, Enum):
87
+ """Confidence levels for clustering decisions."""
88
+ LOW = "low"
89
+ MEDIUM = "medium"
90
+ HIGH = "high"
91
+ VERY_HIGH = "very_high"
92
+
@@ -0,0 +1,158 @@
1
+ """Diff parser for extracting structured information from git diffs."""
2
+
3
+ import re
4
+ from typing import Optional, List, Tuple
5
+ from .models import FileDiff
6
+
7
+
8
+ class DiffParser:
9
+ """Parses git diffs and extracts structured information."""
10
+
11
+ def __init__(self):
12
+ """Initialize the diff parser."""
13
+ self.language_keywords = {
14
+ ".py": ["def ", "class ", "import ", "from "],
15
+ ".js": ["function ", "class ", "import ", "export "],
16
+ ".ts": ["interface ", "type ", "class ", "function ", "import "],
17
+ ".tsx": ["interface ", "function ", "export ", "const ", "import "],
18
+ ".jsx": ["function ", "export ", "import ", "const "],
19
+ ".java": ["class ", "public ", "private ", "interface "],
20
+ ".cpp": ["class ", "void ", "int ", "#include "],
21
+ ".go": ["func ", "type ", "package ", "import "],
22
+ ".rs": ["fn ", "struct ", "impl ", "use "],
23
+ ".rb": ["def ", "class ", "module ", "require "],
24
+ }
25
+
26
+ def parse_diff(self, raw_diff: str, file_path: str) -> List[str]:
27
+ """Extract meaningful changes from a raw diff.
28
+
29
+ Args:
30
+ raw_diff: Raw diff content from git.
31
+ file_path: Path to the file.
32
+
33
+ Returns:
34
+ List of change descriptions.
35
+ """
36
+ if not raw_diff.strip():
37
+ return []
38
+
39
+ changes = []
40
+
41
+ # Detect file extension
42
+ file_ext = self._get_file_extension(file_path)
43
+
44
+ # Parse diff lines
45
+ lines = raw_diff.split('\n')
46
+ added_lines = [l[1:] for l in lines if l.startswith('+') and not l.startswith('+++')]
47
+ removed_lines = [l[1:] for l in lines if l.startswith('-') and not l.startswith('---')]
48
+
49
+ # Extract high-level changes
50
+ if added_lines and not removed_lines:
51
+ changes.append("Added new content")
52
+ elif removed_lines and not added_lines:
53
+ changes.append("Removed content")
54
+ elif added_lines and removed_lines:
55
+ changes.append("Modified content")
56
+
57
+ # Detect function/class changes
58
+ keywords = self.language_keywords.get(file_ext, [])
59
+
60
+ for line in added_lines:
61
+ for keyword in keywords:
62
+ if keyword in line:
63
+ # Extract the identifier
64
+ match = re.search(rf"{keyword}(\w+)", line)
65
+ if match:
66
+ identifier = match.group(1)
67
+ changes.append(f"Added {keyword.strip().rstrip('(')} '{identifier}'")
68
+ break
69
+
70
+ for line in removed_lines:
71
+ for keyword in keywords:
72
+ if keyword in line:
73
+ match = re.search(rf"{keyword}(\w+)", line)
74
+ if match:
75
+ identifier = match.group(1)
76
+ changes.append(f"Removed {keyword.strip().rstrip('(')} '{identifier}'")
77
+ break
78
+
79
+ # Count stats
80
+ num_added = len([l for l in lines if l.startswith('+')])
81
+ num_removed = len([l for l in lines if l.startswith('-')])
82
+
83
+ if num_added > 0 or num_removed > 0:
84
+ changes.append(f"{num_added} lines added, {num_removed} lines removed")
85
+
86
+ return changes
87
+
88
+ def extract_summary(self, raw_diff: str, file_path: str) -> str:
89
+ """Extract a one-line summary of changes.
90
+
91
+ Args:
92
+ raw_diff: Raw diff content from git.
93
+ file_path: Path to the file.
94
+
95
+ Returns:
96
+ One-line summary of changes.
97
+ """
98
+ changes = self.parse_diff(raw_diff, file_path)
99
+
100
+ if not changes:
101
+ return "Modified file"
102
+
103
+ # Take the most significant changes
104
+ primary_changes = [c for c in changes if 'Added' in c or 'Removed' in c]
105
+
106
+ if primary_changes:
107
+ return primary_changes[0]
108
+
109
+ return changes[0]
110
+
111
+ @staticmethod
112
+ def _get_file_extension(file_path: str) -> str:
113
+ """Get file extension."""
114
+ if '.' not in file_path:
115
+ return ""
116
+
117
+ ext = '.' + file_path.split('.')[-1]
118
+ return ext
119
+
120
+ @staticmethod
121
+ def get_file_type(file_path: str) -> str:
122
+ """Determine file type category.
123
+
124
+ Args:
125
+ file_path: Path to file.
126
+
127
+ Returns:
128
+ File type category.
129
+ """
130
+ file_lower = file_path.lower()
131
+
132
+ # Frontend files
133
+ if any(x in file_lower for x in ['.jsx', '.tsx', '.vue', '.css', '.scss']):
134
+ return "frontend"
135
+
136
+ # Backend files
137
+ if any(x in file_lower for x in ['.py', '.java', '.go', '.rs', '.rb', '.php']):
138
+ return "backend"
139
+
140
+ # Config/build files
141
+ if any(x in file_lower for x in ['package.json', 'pyproject.toml', 'requirements.txt',
142
+ 'dockerfile', '.env', 'config.yaml']):
143
+ return "config"
144
+
145
+ # Test files
146
+ if 'test' in file_lower or 'spec' in file_lower:
147
+ return "test"
148
+
149
+ # Documentation
150
+ if any(x in file_lower for x in ['.md', '.rst', '.txt']):
151
+ return "docs"
152
+
153
+ # Database/data files
154
+ if any(x in file_lower for x in ['.sql', '.json', '.xml', '.csv']):
155
+ return "data"
156
+
157
+ return "other"
158
+
@@ -0,0 +1,7 @@
1
+ """Embeddings package."""
2
+
3
+ from .generator import EmbeddingsGenerator
4
+ from .similarity import SimilarityGraph
5
+
6
+ __all__ = ["EmbeddingsGenerator", "SimilarityGraph"]
7
+
@@ -0,0 +1,128 @@
1
+ """Semantic embeddings generation."""
2
+
3
+ from typing import List, Optional
4
+ import numpy as np
5
+
6
+ from ..models import FileDiff, CommitCluster
7
+ from ..logger import get_logger
8
+ from ..constants import DEFAULT_EMBEDDING_MODEL
9
+ from ..cache import cache_manager
10
+
11
+ logger = get_logger("embeddings.generator")
12
+
13
+
14
+ class EmbeddingsGenerator:
15
+ """Generates semantic embeddings using sentence transformers."""
16
+
17
+ def __init__(self, model_name: str = DEFAULT_EMBEDDING_MODEL):
18
+ """Initialize embeddings generator.
19
+
20
+ Args:
21
+ model_name: Sentence transformer model name.
22
+ """
23
+ self.model_name = model_name
24
+ self.model = None
25
+ self._init_model()
26
+
27
+ def _init_model(self):
28
+ """Initialize the embedding model."""
29
+ try:
30
+ from sentence_transformers import SentenceTransformer
31
+ logger.info(f"Loading embedding model: {self.model_name}")
32
+ self.model = SentenceTransformer(self.model_name)
33
+ logger.debug(f"Model loaded successfully")
34
+ except Exception as e:
35
+ logger.error(f"Failed to load embedding model: {e}")
36
+ raise
37
+
38
+ def generate_embedding(self, text: str) -> Optional[List[float]]:
39
+ """Generate embedding for text.
40
+
41
+ Args:
42
+ text: Text to embed.
43
+
44
+ Returns:
45
+ Embedding vector or None.
46
+ """
47
+ if not text or not self.model:
48
+ return None
49
+
50
+ cached = cache_manager.get("embeddings", text)
51
+ if cached is not None:
52
+ return cached
53
+
54
+ try:
55
+ embeddings = self.model.encode(text, convert_to_numpy=True)
56
+ result = embeddings.tolist()
57
+ cache_manager.set("embeddings", text, result)
58
+ return result
59
+ except Exception as e:
60
+ logger.error(f"Failed to generate embedding: {e}")
61
+ return None
62
+
63
+ def generate_file_embedding(self, file_diff: FileDiff) -> Optional[List[float]]:
64
+ """Generate embedding for a file diff.
65
+
66
+ Args:
67
+ file_diff: FileDiff object.
68
+
69
+ Returns:
70
+ Embedding vector or None.
71
+ """
72
+ # Combine multiple text sources for richer embedding
73
+ text_parts = [
74
+ file_diff.file_path,
75
+ file_diff.status,
76
+ file_diff.summary or "",
77
+ ]
78
+
79
+ text = " ".join(text_parts).strip()
80
+
81
+ if not text:
82
+ return None
83
+
84
+ return self.generate_embedding(text)
85
+
86
+ def compute_similarity(self, embedding1: List[float],
87
+ embedding2: List[float]) -> float:
88
+ """Compute cosine similarity between two embeddings.
89
+
90
+ Args:
91
+ embedding1: First embedding.
92
+ embedding2: Second embedding.
93
+
94
+ Returns:
95
+ Similarity score (0-1).
96
+ """
97
+ if not embedding1 or not embedding2:
98
+ return 0.0
99
+
100
+ arr1 = np.array(embedding1)
101
+ arr2 = np.array(embedding2)
102
+
103
+ # Cosine similarity
104
+ dot = np.dot(arr1, arr2)
105
+ norm1 = np.linalg.norm(arr1)
106
+ norm2 = np.linalg.norm(arr2)
107
+
108
+ if norm1 == 0 or norm2 == 0:
109
+ return 0.0
110
+
111
+ return float(dot / (norm1 * norm2))
112
+
113
+ def compute_cluster_similarity(self, cluster1: CommitCluster,
114
+ cluster2: CommitCluster) -> float:
115
+ """Compute similarity between two clusters.
116
+
117
+ Args:
118
+ cluster1: First cluster.
119
+ cluster2: Second cluster.
120
+
121
+ Returns:
122
+ Similarity score (0-1).
123
+ """
124
+ if not cluster1.embedding or not cluster2.embedding:
125
+ return 0.0
126
+
127
+ return self.compute_similarity(cluster1.embedding, cluster2.embedding)
128
+