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
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,7 @@
1
+ """Clustering package."""
2
+
3
+ from .semantic import SemanticClustering
4
+ from .heuristic import HeuristicClustering
5
+
6
+ __all__ = ["SemanticClustering", "HeuristicClustering"]
7
+
@@ -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
+