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
smart_commit/cli.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Smart Commit v2 command line interface."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .analysis import DiffParser, Summarizer
|
|
10
|
+
from .analysis.symbol_extractor import SymbolExtractor
|
|
11
|
+
from .clustering import HeuristicClustering, SemanticClustering
|
|
12
|
+
from .commit_messages import CommitMessageGenerator
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .constants import CONFIG_FILE
|
|
15
|
+
from .exceptions import InvalidRepositoryError, NoChangesError, SmartCommitException
|
|
16
|
+
from .execution import Committer
|
|
17
|
+
from .git import GitService, RepositoryScanner
|
|
18
|
+
from .logger import get_logger, setup_logging
|
|
19
|
+
from .models import CommitCluster
|
|
20
|
+
from .plugins import PluginRegistry
|
|
21
|
+
from .preview import PreviewRenderer, InteractiveEditor
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
25
|
+
except ImportError:
|
|
26
|
+
Progress = None
|
|
27
|
+
SpinnerColumn = None
|
|
28
|
+
TextColumn = None
|
|
29
|
+
|
|
30
|
+
logger = get_logger("cli")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_clusters(
|
|
34
|
+
repo_path: Path,
|
|
35
|
+
*,
|
|
36
|
+
no_ai: bool = False,
|
|
37
|
+
offline: bool = False,
|
|
38
|
+
provider: Optional[str] = None,
|
|
39
|
+
model: Optional[str] = None,
|
|
40
|
+
max_files: Optional[int] = None,
|
|
41
|
+
) -> tuple[list[CommitCluster], object]:
|
|
42
|
+
"""Run the PRD pipeline up to commit proposals."""
|
|
43
|
+
config = Config.load()
|
|
44
|
+
if provider:
|
|
45
|
+
config.provider = provider
|
|
46
|
+
if model:
|
|
47
|
+
config.model = model
|
|
48
|
+
if max_files:
|
|
49
|
+
config.max_files_per_commit = max_files
|
|
50
|
+
|
|
51
|
+
snapshot = RepositoryScanner(repo_path).scan()
|
|
52
|
+
parser = DiffParser()
|
|
53
|
+
extractor = SymbolExtractor()
|
|
54
|
+
summarizer = Summarizer()
|
|
55
|
+
|
|
56
|
+
for file_diff in snapshot.all_files:
|
|
57
|
+
parser.enrich_file_diff(file_diff)
|
|
58
|
+
file_diff.extracted_symbols = extractor.extract_symbols(file_diff)
|
|
59
|
+
file_diff.summary = summarizer.summarize_file_diff(file_diff)
|
|
60
|
+
|
|
61
|
+
if offline or no_ai or not config.use_local_embeddings:
|
|
62
|
+
clusters = HeuristicClustering().cluster_files(snapshot.all_files, config.max_files_per_commit)
|
|
63
|
+
else:
|
|
64
|
+
clusters = SemanticClustering(config.similarity_threshold).cluster_files(snapshot.all_files, repo_path=repo_path)
|
|
65
|
+
|
|
66
|
+
for cluster in clusters:
|
|
67
|
+
cluster.summary = summarizer.summarize_cluster(cluster.files)
|
|
68
|
+
if not cluster.reason:
|
|
69
|
+
cluster.reason = "Grouped by deterministic path, summary, symbol, and import analysis."
|
|
70
|
+
|
|
71
|
+
if not offline and not no_ai:
|
|
72
|
+
clusters = review_with_ai(clusters, config)
|
|
73
|
+
|
|
74
|
+
message_generator = CommitMessageGenerator(config)
|
|
75
|
+
for cluster in clusters:
|
|
76
|
+
cluster.commit_type = message_generator._infer_commit_type(cluster)
|
|
77
|
+
cluster.message = message_generator.generate_title(cluster)
|
|
78
|
+
|
|
79
|
+
return clusters, snapshot
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def review_with_ai(clusters: list[CommitCluster], config: Config) -> list[CommitCluster]:
|
|
83
|
+
"""Let the LLM review clusters without depending on it for correctness."""
|
|
84
|
+
if not config.api_key:
|
|
85
|
+
return clusters
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
from .ai import ClusterReviewer, LLMClient
|
|
89
|
+
|
|
90
|
+
reviewer = ClusterReviewer(LLMClient(config))
|
|
91
|
+
for cluster in clusters:
|
|
92
|
+
review = reviewer.review_cluster(cluster)
|
|
93
|
+
if review.get("status") == "error":
|
|
94
|
+
continue
|
|
95
|
+
cluster.confidence = float(review.get("confidence", cluster.confidence))
|
|
96
|
+
if review.get("reason"):
|
|
97
|
+
cluster.reason = review["reason"]
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
logger.warning("AI review skipped: %s", exc)
|
|
100
|
+
return clusters
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_scan_summary(snapshot: object) -> None:
|
|
104
|
+
"""Print repository scan summary."""
|
|
105
|
+
print(f" Found {snapshot.total_files_changed} changed file(s)")
|
|
106
|
+
print(f" +{snapshot.total_additions} additions, -{snapshot.total_deletions} deletions")
|
|
107
|
+
print(f" Branch: {snapshot.branch}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def command_run(args: argparse.Namespace) -> int:
|
|
111
|
+
"""Analyze changes, preview logical commits, and commit after approval."""
|
|
112
|
+
repo_path = Path(args.path or Path.cwd())
|
|
113
|
+
try:
|
|
114
|
+
print(f"Smart Commit v{__version__}")
|
|
115
|
+
print("Organize Git history before you commit\n")
|
|
116
|
+
if Progress:
|
|
117
|
+
with Progress(
|
|
118
|
+
SpinnerColumn(),
|
|
119
|
+
TextColumn("[progress.description]{task.description}"),
|
|
120
|
+
transient=True,
|
|
121
|
+
) as progress:
|
|
122
|
+
progress.add_task(description="Scanning repository and analyzing files...", total=None)
|
|
123
|
+
clusters, snapshot = build_clusters(
|
|
124
|
+
repo_path,
|
|
125
|
+
no_ai=args.no_ai,
|
|
126
|
+
offline=args.offline,
|
|
127
|
+
provider=args.provider,
|
|
128
|
+
model=args.model,
|
|
129
|
+
max_files=args.max_files,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
print("Step 1: Scanning repository...")
|
|
133
|
+
clusters, snapshot = build_clusters(
|
|
134
|
+
repo_path,
|
|
135
|
+
no_ai=args.no_ai,
|
|
136
|
+
offline=args.offline,
|
|
137
|
+
provider=args.provider,
|
|
138
|
+
model=args.model,
|
|
139
|
+
max_files=args.max_files,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
print("Step 1: Analysis Complete")
|
|
143
|
+
print_scan_summary(snapshot)
|
|
144
|
+
|
|
145
|
+
print("\nStep 2: Proposed commit plan")
|
|
146
|
+
renderer = PreviewRenderer()
|
|
147
|
+
|
|
148
|
+
if args.dry_run:
|
|
149
|
+
renderer.render_clusters(clusters, snapshot.total_files_changed)
|
|
150
|
+
print("Dry run complete. No commits were created.")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
if not args.yes:
|
|
154
|
+
editor = InteractiveEditor(clusters, snapshot.total_files_changed, renderer)
|
|
155
|
+
response = editor.edit()
|
|
156
|
+
if response in {"n", "d"}:
|
|
157
|
+
print("Cancelled. No commits were created.")
|
|
158
|
+
return 0
|
|
159
|
+
else:
|
|
160
|
+
renderer.render_clusters(clusters, snapshot.total_files_changed)
|
|
161
|
+
|
|
162
|
+
print("\nStep 3: Creating commits")
|
|
163
|
+
commit_hashes = Committer(GitService(repo_path)).commit_clusters(clusters)
|
|
164
|
+
print(f"Success: created {len(commit_hashes)} commit(s)")
|
|
165
|
+
for index, commit_hash in enumerate(commit_hashes, 1):
|
|
166
|
+
print(f" {index}. {commit_hash[:7]}")
|
|
167
|
+
return 0
|
|
168
|
+
except NoChangesError:
|
|
169
|
+
print("No changes found.")
|
|
170
|
+
return 0
|
|
171
|
+
except (InvalidRepositoryError, SmartCommitException, RuntimeError) as exc:
|
|
172
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
logger.exception("Unexpected failure")
|
|
176
|
+
print(f"Unexpected error: {exc}", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def command_preview(args: argparse.Namespace) -> int:
|
|
181
|
+
"""Preview without committing."""
|
|
182
|
+
args.dry_run = True
|
|
183
|
+
args.yes = False
|
|
184
|
+
return command_run(args)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def command_undo(args: argparse.Namespace) -> int:
|
|
188
|
+
"""Undo the last Smart Commit session."""
|
|
189
|
+
git_service = GitService(Path(args.path or Path.cwd()))
|
|
190
|
+
session = git_service.get_last_session()
|
|
191
|
+
if not session:
|
|
192
|
+
print("No previous Smart Commit session found.")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
print(f"Session: {session.session_id}")
|
|
196
|
+
print(f"Branch: {session.branch}")
|
|
197
|
+
print(f"Commits: {len(session.commit_hashes)}")
|
|
198
|
+
if args.yes or input("Soft reset these commits? [y/N] ").lower().strip() == "y":
|
|
199
|
+
if git_service.undo_last_session():
|
|
200
|
+
print("Session undone. Changes are staged for review.")
|
|
201
|
+
return 0
|
|
202
|
+
print("Failed to undo the last session.", file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
print("Cancelled.")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def command_config(args: argparse.Namespace) -> int:
|
|
209
|
+
"""Show or update configuration."""
|
|
210
|
+
config = Config.load()
|
|
211
|
+
changed = False
|
|
212
|
+
if args.provider:
|
|
213
|
+
config.provider = args.provider
|
|
214
|
+
changed = True
|
|
215
|
+
if args.model:
|
|
216
|
+
config.model = args.model
|
|
217
|
+
changed = True
|
|
218
|
+
if args.api_key:
|
|
219
|
+
config.api_key = args.api_key
|
|
220
|
+
changed = True
|
|
221
|
+
if changed:
|
|
222
|
+
config.save_to_file(CONFIG_FILE)
|
|
223
|
+
print(f"Configuration saved to {CONFIG_FILE}")
|
|
224
|
+
return 0
|
|
225
|
+
if args.show:
|
|
226
|
+
print("Smart Commit Configuration")
|
|
227
|
+
print(f" Provider: {config.provider}")
|
|
228
|
+
print(f" Model: {config.model}")
|
|
229
|
+
print(f" Embedding model: {config.embedding_model}")
|
|
230
|
+
print(f" Max files per commit: {config.max_files_per_commit}")
|
|
231
|
+
print(f" Interactive: {config.interactive}")
|
|
232
|
+
print(f" Auto push: {config.auto_push}")
|
|
233
|
+
return 0
|
|
234
|
+
print("Use --show or provide --provider, --model, or --api-key.")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def command_doctor(args: argparse.Namespace) -> int:
|
|
239
|
+
"""Run diagnostic checks."""
|
|
240
|
+
checks = []
|
|
241
|
+
try:
|
|
242
|
+
scanner = RepositoryScanner(Path(args.path or Path.cwd()))
|
|
243
|
+
checks.append(("Git repository", "ok", str(scanner.repo_path)))
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
checks.append(("Git repository", "fail", str(exc)))
|
|
246
|
+
|
|
247
|
+
for name, module in [
|
|
248
|
+
("Typer", "typer"),
|
|
249
|
+
("Rich", "rich"),
|
|
250
|
+
("GitPython", "git"),
|
|
251
|
+
("Pydantic", "pydantic"),
|
|
252
|
+
("YAML", "yaml"),
|
|
253
|
+
]:
|
|
254
|
+
try:
|
|
255
|
+
__import__(module)
|
|
256
|
+
checks.append((name, "ok", "installed"))
|
|
257
|
+
except ImportError:
|
|
258
|
+
checks.append((name, "optional", "not installed"))
|
|
259
|
+
|
|
260
|
+
config = Config.load()
|
|
261
|
+
checks.append(("LLM API key", "ok" if config.api_key else "optional", config.provider))
|
|
262
|
+
checks.append(("Auto push", "ok", "disabled" if not config.auto_push else "enabled"))
|
|
263
|
+
|
|
264
|
+
print("Smart Commit Doctor")
|
|
265
|
+
for name, status, details in checks:
|
|
266
|
+
print(f" {name:16} {status:8} {details}")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def command_explain(args: argparse.Namespace) -> int:
|
|
271
|
+
"""Explain grouping decisions."""
|
|
272
|
+
clusters, snapshot = build_clusters(Path(args.path or Path.cwd()), offline=args.offline)
|
|
273
|
+
print_scan_summary(snapshot)
|
|
274
|
+
for index, cluster in enumerate(clusters, 1):
|
|
275
|
+
print(f"\nCommit {index}: {cluster.message}")
|
|
276
|
+
print(f"Reason: {cluster.reason}")
|
|
277
|
+
print(f"Confidence: {cluster.confidence:.0%}")
|
|
278
|
+
for file_diff in cluster.files:
|
|
279
|
+
print(f" {file_diff.status} {file_diff.file_path}")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def command_plugins(_: argparse.Namespace) -> int:
|
|
284
|
+
"""List plugins."""
|
|
285
|
+
registry = PluginRegistry()
|
|
286
|
+
print("Smart Commit Plugins")
|
|
287
|
+
for name in registry.list_plugins():
|
|
288
|
+
plugin = registry.get_plugin(name)
|
|
289
|
+
print(f" {plugin.name} {plugin.version}: {plugin.description}")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def command_benchmark(args: argparse.Namespace) -> int:
|
|
294
|
+
"""Run performance benchmark."""
|
|
295
|
+
from .benchmark import BenchmarkHarness
|
|
296
|
+
harness = BenchmarkHarness(Path(args.path or Path.cwd()))
|
|
297
|
+
harness.run()
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def command_init(args: argparse.Namespace) -> int:
|
|
302
|
+
"""Initialize repository configuration."""
|
|
303
|
+
scanner = RepositoryScanner(Path(args.path or Path.cwd()))
|
|
304
|
+
if not CONFIG_FILE.exists():
|
|
305
|
+
Config().save_to_file(CONFIG_FILE)
|
|
306
|
+
print(f"Smart Commit initialized for {scanner.repo_path}")
|
|
307
|
+
print(f"Config: {CONFIG_FILE}")
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def command_version(_: argparse.Namespace) -> int:
|
|
312
|
+
"""Show version information."""
|
|
313
|
+
print(f"smart-commit {__version__}")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
318
|
+
"""Build the CLI parser."""
|
|
319
|
+
parser = argparse.ArgumentParser(prog="smart-commit")
|
|
320
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
|
321
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
322
|
+
|
|
323
|
+
run_parser = subparsers.add_parser("run", help="Analyze and create commits")
|
|
324
|
+
add_pipeline_options(run_parser)
|
|
325
|
+
run_parser.add_argument("--yes", action="store_true", help="Execute without prompting")
|
|
326
|
+
run_parser.add_argument("--dry-run", action="store_true", help="Preview only")
|
|
327
|
+
run_parser.set_defaults(func=command_run)
|
|
328
|
+
|
|
329
|
+
preview_parser = subparsers.add_parser("preview", help="Preview proposed commits")
|
|
330
|
+
add_pipeline_options(preview_parser)
|
|
331
|
+
preview_parser.set_defaults(func=command_preview)
|
|
332
|
+
|
|
333
|
+
undo_parser = subparsers.add_parser("undo", help="Undo the last Smart Commit session")
|
|
334
|
+
undo_parser.add_argument("--path", "-p")
|
|
335
|
+
undo_parser.add_argument("--yes", action="store_true")
|
|
336
|
+
undo_parser.set_defaults(func=command_undo)
|
|
337
|
+
|
|
338
|
+
config_parser = subparsers.add_parser("config", help="Show or update config")
|
|
339
|
+
config_parser.add_argument("--show", action="store_true")
|
|
340
|
+
config_parser.add_argument("--provider")
|
|
341
|
+
config_parser.add_argument("--model")
|
|
342
|
+
config_parser.add_argument("--api-key")
|
|
343
|
+
config_parser.set_defaults(func=command_config)
|
|
344
|
+
|
|
345
|
+
doctor_parser = subparsers.add_parser("doctor", help="Run diagnostics")
|
|
346
|
+
doctor_parser.add_argument("--path", "-p")
|
|
347
|
+
doctor_parser.set_defaults(func=command_doctor)
|
|
348
|
+
|
|
349
|
+
explain_parser = subparsers.add_parser("explain", help="Explain grouping decisions")
|
|
350
|
+
explain_parser.add_argument("--path", "-p")
|
|
351
|
+
explain_parser.add_argument("--offline", action="store_true", default=True)
|
|
352
|
+
explain_parser.set_defaults(func=command_explain)
|
|
353
|
+
|
|
354
|
+
subparsers.add_parser("plugins", help="List plugins").set_defaults(func=command_plugins)
|
|
355
|
+
|
|
356
|
+
init_parser = subparsers.add_parser("init", help="Initialize Smart Commit")
|
|
357
|
+
init_parser.add_argument("--path", "-p")
|
|
358
|
+
init_parser.set_defaults(func=command_init)
|
|
359
|
+
|
|
360
|
+
subparsers.add_parser("version", help="Show version").set_defaults(func=command_version)
|
|
361
|
+
|
|
362
|
+
benchmark_parser = subparsers.add_parser("benchmark", help="Run performance benchmark")
|
|
363
|
+
benchmark_parser.add_argument("--path", "-p")
|
|
364
|
+
benchmark_parser.set_defaults(func=command_benchmark)
|
|
365
|
+
|
|
366
|
+
return parser
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def add_pipeline_options(parser: argparse.ArgumentParser) -> None:
|
|
370
|
+
"""Add shared pipeline flags."""
|
|
371
|
+
parser.add_argument("--path", "-p")
|
|
372
|
+
parser.add_argument("--no-ai", action="store_true", help="Disable LLM review")
|
|
373
|
+
parser.add_argument("--offline", action="store_true", help="Disable AI and embeddings")
|
|
374
|
+
parser.add_argument("--provider")
|
|
375
|
+
parser.add_argument("--model")
|
|
376
|
+
parser.add_argument("--max-files", type=int)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def app(argv: Optional[list[str]] = None) -> int:
|
|
380
|
+
"""CLI entry point used by the console script."""
|
|
381
|
+
parser = build_parser()
|
|
382
|
+
args = parser.parse_args(argv)
|
|
383
|
+
setup_logging(verbose=getattr(args, "verbose", False))
|
|
384
|
+
if not getattr(args, "command", None):
|
|
385
|
+
parser.print_help()
|
|
386
|
+
return 0
|
|
387
|
+
return args.func(args)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
raise SystemExit(app())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Graph-based clustering using community detection."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ..models import FileDiff, CommitCluster
|
|
7
|
+
from ..logger import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger("clustering.graph_clustering")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphClustering:
|
|
13
|
+
"""Clusters files using networkx community detection."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.networkx_available = False
|
|
17
|
+
try:
|
|
18
|
+
import networkx as nx
|
|
19
|
+
self.nx = nx
|
|
20
|
+
self.networkx_available = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
logger.warning("networkx not installed; GraphClustering unavailable")
|
|
23
|
+
|
|
24
|
+
def is_available(self) -> bool:
|
|
25
|
+
return self.networkx_available
|
|
26
|
+
|
|
27
|
+
def cluster_files(self, similarity_matrix: np.ndarray, file_diffs: List[FileDiff]) -> List[int]:
|
|
28
|
+
"""Cluster files using Louvain community detection.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
similarity_matrix: N x N similarity matrix.
|
|
32
|
+
file_diffs: List of FileDiffs.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of cluster labels corresponding to file_diffs.
|
|
36
|
+
"""
|
|
37
|
+
if not self.networkx_available:
|
|
38
|
+
# Fallback to single cluster
|
|
39
|
+
return [0] * len(file_diffs)
|
|
40
|
+
|
|
41
|
+
G = self.nx.Graph()
|
|
42
|
+
n = len(file_diffs)
|
|
43
|
+
|
|
44
|
+
# Add nodes
|
|
45
|
+
for i in range(n):
|
|
46
|
+
G.add_node(i)
|
|
47
|
+
|
|
48
|
+
# Add edges for similarities > threshold (e.g., 0.3)
|
|
49
|
+
for i in range(n):
|
|
50
|
+
for j in range(i + 1, n):
|
|
51
|
+
sim = similarity_matrix[i][j]
|
|
52
|
+
if sim > 0.3:
|
|
53
|
+
G.add_edge(i, j, weight=sim)
|
|
54
|
+
|
|
55
|
+
# If the graph is entirely disconnected or too small, fallback gracefully
|
|
56
|
+
if G.number_of_edges() == 0:
|
|
57
|
+
return list(range(n))
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from networkx.algorithms.community import louvain_communities
|
|
61
|
+
communities = louvain_communities(G, weight='weight')
|
|
62
|
+
|
|
63
|
+
labels = [0] * n
|
|
64
|
+
for label, comm in enumerate(communities):
|
|
65
|
+
for node in comm:
|
|
66
|
+
labels[node] = label
|
|
67
|
+
|
|
68
|
+
return labels
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.warning(f"Louvain clustering failed: {e}")
|
|
72
|
+
# Fallback to greedy/connected components
|
|
73
|
+
communities = list(self.nx.connected_components(G))
|
|
74
|
+
labels = [0] * n
|
|
75
|
+
for label, comm in enumerate(communities):
|
|
76
|
+
for node in comm:
|
|
77
|
+
labels[node] = label
|
|
78
|
+
return labels
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from ..models import FileDiff, CommitCluster
|
|
2
|
+
from ..logger import get_logger
|
|
3
|
+
|
|
4
|
+
logger = get_logger("clustering.heuristic")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HeuristicClustering:
|
|
8
|
+
"""Clustering based on static heuristics."""
|
|
9
|
+
|
|
10
|
+
def cluster_files(
|
|
11
|
+
self,
|
|
12
|
+
file_diffs: list[FileDiff],
|
|
13
|
+
max_files_per_commit: int = 8,
|
|
14
|
+
) -> list[CommitCluster]:
|
|
15
|
+
"""Cluster files using deterministic path and change-type signals."""
|
|
16
|
+
buckets: dict[str, list[FileDiff]] = {}
|
|
17
|
+
|
|
18
|
+
for file_diff in file_diffs:
|
|
19
|
+
key = self._group_key(file_diff.file_path)
|
|
20
|
+
buckets.setdefault(key, []).append(file_diff)
|
|
21
|
+
|
|
22
|
+
clusters: list[CommitCluster] = []
|
|
23
|
+
for key, files in sorted(buckets.items()):
|
|
24
|
+
for chunk_index, chunk in enumerate(self._chunks(files, max_files_per_commit)):
|
|
25
|
+
cluster = CommitCluster(
|
|
26
|
+
id=f"heuristic_{len(clusters)}",
|
|
27
|
+
files=chunk,
|
|
28
|
+
confidence=self._confidence_for_key(key, chunk),
|
|
29
|
+
reason=self._reason_for_key(key, chunk),
|
|
30
|
+
)
|
|
31
|
+
clusters.append(cluster)
|
|
32
|
+
|
|
33
|
+
logger.debug("Created %s heuristic clusters", len(clusters))
|
|
34
|
+
return clusters
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def cluster_by_directory(file_diffs: list[FileDiff]) -> list[CommitCluster]:
|
|
38
|
+
"""Cluster files by directory."""
|
|
39
|
+
clusters_dict = {}
|
|
40
|
+
|
|
41
|
+
for file_diff in file_diffs:
|
|
42
|
+
directory = HeuristicClustering._get_directory(file_diff.file_path)
|
|
43
|
+
|
|
44
|
+
if directory not in clusters_dict:
|
|
45
|
+
clusters_dict[directory] = []
|
|
46
|
+
|
|
47
|
+
clusters_dict[directory].append(file_diff)
|
|
48
|
+
|
|
49
|
+
clusters = []
|
|
50
|
+
for idx, (directory, files) in enumerate(sorted(clusters_dict.items())):
|
|
51
|
+
cluster = CommitCluster(
|
|
52
|
+
id=f"heuristic_{idx}",
|
|
53
|
+
files=files
|
|
54
|
+
)
|
|
55
|
+
clusters.append(cluster)
|
|
56
|
+
|
|
57
|
+
logger.debug(f"Created {len(clusters)} clusters by directory")
|
|
58
|
+
return clusters
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def cluster_by_file_type(file_diffs: list[FileDiff]) -> list[CommitCluster]:
|
|
62
|
+
"""Cluster files by type (frontend, backend, test, docs)."""
|
|
63
|
+
clusters_dict = {}
|
|
64
|
+
|
|
65
|
+
for file_diff in file_diffs:
|
|
66
|
+
file_type = HeuristicClustering._get_file_type(file_diff.file_path)
|
|
67
|
+
|
|
68
|
+
if file_type not in clusters_dict:
|
|
69
|
+
clusters_dict[file_type] = []
|
|
70
|
+
|
|
71
|
+
clusters_dict[file_type].append(file_diff)
|
|
72
|
+
|
|
73
|
+
clusters = []
|
|
74
|
+
for idx, (file_type, files) in enumerate(sorted(clusters_dict.items())):
|
|
75
|
+
cluster = CommitCluster(
|
|
76
|
+
id=f"type_{idx}",
|
|
77
|
+
files=files
|
|
78
|
+
)
|
|
79
|
+
clusters.append(cluster)
|
|
80
|
+
|
|
81
|
+
logger.debug(f"Created {len(clusters)} clusters by file type")
|
|
82
|
+
return clusters
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _get_directory(file_path: str) -> str:
|
|
86
|
+
"""Extract top-level directory from path."""
|
|
87
|
+
parts = file_path.replace("\\", "/").split("/")
|
|
88
|
+
return parts[0] if parts else "root"
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _get_file_type(file_path: str) -> str:
|
|
92
|
+
"""Determine file type."""
|
|
93
|
+
path_lower = file_path.lower()
|
|
94
|
+
|
|
95
|
+
if any(x in path_lower for x in ['.jsx', '.tsx', '.vue', '.css', '.scss']):
|
|
96
|
+
return "frontend"
|
|
97
|
+
elif any(x in path_lower for x in ['.py', '.java', '.go', '.rs', '.rb', '.php']):
|
|
98
|
+
return "backend"
|
|
99
|
+
elif 'test' in path_lower or 'spec' in path_lower:
|
|
100
|
+
return "test"
|
|
101
|
+
elif any(x in path_lower for x in ['.md', '.txt', '.rst']):
|
|
102
|
+
return "docs"
|
|
103
|
+
elif any(x in path_lower for x in ['.json', '.yaml', '.yml', '.toml']):
|
|
104
|
+
return "config"
|
|
105
|
+
else:
|
|
106
|
+
return "other"
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def _group_key(cls, file_path: str) -> str:
|
|
110
|
+
path = file_path.replace("\\", "/").lower()
|
|
111
|
+
parts = [part for part in path.split("/") if part]
|
|
112
|
+
filename = parts[-1] if parts else path
|
|
113
|
+
stem = filename.rsplit(".", 1)[0]
|
|
114
|
+
|
|
115
|
+
if cls._is_docs(path):
|
|
116
|
+
return "docs"
|
|
117
|
+
if cls._is_test(path):
|
|
118
|
+
domain = cls._domain_from_path(parts, stem)
|
|
119
|
+
return f"test:{domain}"
|
|
120
|
+
if cls._is_config(path):
|
|
121
|
+
return "config"
|
|
122
|
+
|
|
123
|
+
domain = cls._domain_from_path(parts, stem)
|
|
124
|
+
file_type = cls._get_file_type(path)
|
|
125
|
+
return f"{file_type}:{domain}"
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _domain_from_path(parts: list[str], stem: str) -> str:
|
|
129
|
+
ignored = {"src", "lib", "app", "apps", "packages", "tests", "test", "spec", "smart_commit"}
|
|
130
|
+
for part in parts[:-1]:
|
|
131
|
+
if part not in ignored:
|
|
132
|
+
return part.replace("_", "-")
|
|
133
|
+
|
|
134
|
+
tokens = [token for token in stem.replace("-", "_").split("_") if token]
|
|
135
|
+
generic = {"index", "main", "utils", "helpers", "service", "services", "manager", "client"}
|
|
136
|
+
for token in tokens:
|
|
137
|
+
if token not in generic:
|
|
138
|
+
return token
|
|
139
|
+
return stem or "root"
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _is_docs(path: str) -> bool:
|
|
143
|
+
return path.endswith((".md", ".rst", ".txt")) or "/docs/" in f"/{path}/"
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _is_test(path: str) -> bool:
|
|
147
|
+
normalized = f"/{path}"
|
|
148
|
+
return any(part in normalized for part in ("test_", "_test.", ".test.", ".spec.", "/tests/"))
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _is_config(path: str) -> bool:
|
|
152
|
+
config_names = {"pyproject.toml", "package.json", "tsconfig.json", ".gitignore", ".env.example"}
|
|
153
|
+
return path in config_names or path.endswith((".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"))
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _chunks(files: list[FileDiff], size: int) -> list[list[FileDiff]]:
|
|
157
|
+
return [files[index : index + size] for index in range(0, len(files), max(1, size))]
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _confidence_for_key(key: str, files: list[FileDiff]) -> float:
|
|
161
|
+
if len(files) == 1:
|
|
162
|
+
return 0.72
|
|
163
|
+
if key.startswith(("docs", "config")):
|
|
164
|
+
return 0.86
|
|
165
|
+
return 0.78
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _reason_for_key(key: str, files: list[FileDiff]) -> str:
|
|
169
|
+
if key == "docs":
|
|
170
|
+
return "Documentation changes are grouped separately for a readable history."
|
|
171
|
+
if key == "config":
|
|
172
|
+
return "Configuration and project metadata changes share the same operational concern."
|
|
173
|
+
if key.startswith("test:"):
|
|
174
|
+
return "Test files with the same inferred domain belong together."
|
|
175
|
+
if len(files) == 1:
|
|
176
|
+
return "Single-file change kept as its own focused commit."
|
|
177
|
+
return "Files share an inferred domain from path, filename, and file type."
|
|
178
|
+
|