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.
- smart_commit/__init__.py +6 -0
- smart_commit/ai/__init__.py +7 -0
- smart_commit/ai/client.py +123 -0
- smart_commit/ai/reviewer.py +91 -0
- smart_commit/ai.py +136 -0
- smart_commit/analysis/__init__.py +7 -0
- smart_commit/analysis/ast_parser.py +173 -0
- smart_commit/analysis/dependency_graph.py +114 -0
- smart_commit/analysis/diff_parser.py +172 -0
- smart_commit/analysis/summarizer.py +101 -0
- smart_commit/analysis/symbol_extractor.py +88 -0
- smart_commit/benchmark.py +88 -0
- smart_commit/cache.py +65 -0
- smart_commit/cli.py +391 -0
- smart_commit/clustering/__init__.py +7 -0
- smart_commit/clustering/graph_clustering.py +78 -0
- smart_commit/clustering/heuristic.py +178 -0
- smart_commit/clustering/semantic.py +201 -0
- smart_commit/clustering.py +186 -0
- smart_commit/commit_messages.py +232 -0
- smart_commit/committer.py +91 -0
- smart_commit/config.py +88 -0
- smart_commit/constants.py +92 -0
- smart_commit/diff_parser.py +158 -0
- smart_commit/embeddings/__init__.py +7 -0
- smart_commit/embeddings/generator.py +128 -0
- smart_commit/embeddings/similarity.py +159 -0
- smart_commit/embeddings.py +108 -0
- smart_commit/exceptions.py +102 -0
- smart_commit/execution/__init__.py +6 -0
- smart_commit/execution/committer.py +102 -0
- smart_commit/git/__init__.py +8 -0
- smart_commit/git/safety.py +75 -0
- smart_commit/git/scanner.py +182 -0
- smart_commit/git/service.py +129 -0
- smart_commit/git_service.py +302 -0
- smart_commit/logger.py +99 -0
- smart_commit/models.py +73 -0
- smart_commit/plugins/__init__.py +6 -0
- smart_commit/plugins/base.py +135 -0
- smart_commit/plugins/django.py +40 -0
- smart_commit/plugins/fastapi.py +39 -0
- smart_commit/plugins/react.py +49 -0
- smart_commit/preview/__init__.py +6 -0
- smart_commit/preview/editor.py +224 -0
- smart_commit/preview/renderer.py +94 -0
- smart_commit/preview.py +131 -0
- smart_commit/summarizer.py +93 -0
- smart_commit/utils.py +83 -0
- smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
- smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
- smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
- 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,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,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
|
+
|