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,94 @@
|
|
|
1
|
+
"""Preview renderer with Rich enhancement and plain-text fallback."""
|
|
2
|
+
|
|
3
|
+
from ..constants import MAX_FILES_IN_PREVIEW
|
|
4
|
+
from ..models import CommitCluster
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.tree import Tree
|
|
11
|
+
except ImportError: # pragma: no cover - exercised only without optional UI dependency
|
|
12
|
+
Console = None
|
|
13
|
+
Panel = None
|
|
14
|
+
Table = None
|
|
15
|
+
Tree = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PreviewRenderer:
|
|
19
|
+
"""Renders commit proposals and small interactive prompts."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.console = Console() if Console else None
|
|
23
|
+
|
|
24
|
+
def render_clusters(self, clusters: list[CommitCluster], total_files: int) -> None:
|
|
25
|
+
"""Render all clusters for review."""
|
|
26
|
+
if self.console:
|
|
27
|
+
self._render_rich(clusters, total_files)
|
|
28
|
+
else:
|
|
29
|
+
self._render_plain(clusters, total_files)
|
|
30
|
+
|
|
31
|
+
def _render_rich(self, clusters: list[CommitCluster], total_files: int) -> None:
|
|
32
|
+
self.console.print()
|
|
33
|
+
self.console.print(f"[bold cyan]Smart Commit Preview[/bold cyan] - {len(clusters)} commit(s)")
|
|
34
|
+
self.console.print()
|
|
35
|
+
|
|
36
|
+
for index, cluster in enumerate(clusters, 1):
|
|
37
|
+
message = cluster.message or cluster.summary or "chore: update files"
|
|
38
|
+
tree = Tree(f"[bold]{message}[/bold]")
|
|
39
|
+
for file_diff in cluster.files[:MAX_FILES_IN_PREVIEW]:
|
|
40
|
+
label = f"{file_diff.status} {file_diff.file_path}"
|
|
41
|
+
if file_diff.summary:
|
|
42
|
+
label = f"{label} [dim]- {file_diff.summary}[/dim]"
|
|
43
|
+
tree.add(label)
|
|
44
|
+
remaining = len(cluster.files) - MAX_FILES_IN_PREVIEW
|
|
45
|
+
if remaining > 0:
|
|
46
|
+
tree.add(f"[dim]and {remaining} more[/dim]")
|
|
47
|
+
reason = cluster.reason or "Grouped by deterministic repository analysis."
|
|
48
|
+
self.console.print(
|
|
49
|
+
Panel.fit(
|
|
50
|
+
tree,
|
|
51
|
+
title=f"Commit {index}",
|
|
52
|
+
subtitle=f"confidence: {cluster.confidence:.0%} | {reason}",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
table = Table(title="Commit Summary", show_header=True)
|
|
57
|
+
table.add_column("#", style="cyan")
|
|
58
|
+
table.add_column("Type", style="magenta")
|
|
59
|
+
table.add_column("Files", justify="right", style="green")
|
|
60
|
+
table.add_column("Confidence", justify="right", style="blue")
|
|
61
|
+
table.add_column("Message", style="yellow")
|
|
62
|
+
for index, cluster in enumerate(clusters, 1):
|
|
63
|
+
commit_type = cluster.commit_type.value if cluster.commit_type else "chore"
|
|
64
|
+
table.add_row(
|
|
65
|
+
str(index),
|
|
66
|
+
commit_type,
|
|
67
|
+
str(len(cluster.files)),
|
|
68
|
+
f"{cluster.confidence:.0%}",
|
|
69
|
+
(cluster.message or cluster.summary or "chore: update files")[:72],
|
|
70
|
+
)
|
|
71
|
+
self.console.print()
|
|
72
|
+
self.console.print(table)
|
|
73
|
+
self.console.print(
|
|
74
|
+
f"[bold green]Summary:[/bold green] {len(clusters)} commit(s) covering {total_files} file(s)"
|
|
75
|
+
)
|
|
76
|
+
self.console.print()
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _render_plain(clusters: list[CommitCluster], total_files: int) -> None:
|
|
80
|
+
print()
|
|
81
|
+
print(f"Smart Commit Preview - {len(clusters)} commit(s)")
|
|
82
|
+
for index, cluster in enumerate(clusters, 1):
|
|
83
|
+
print(f"\nCommit {index}: {cluster.message or cluster.summary or 'chore: update files'}")
|
|
84
|
+
print(f" Confidence: {cluster.confidence:.0%}")
|
|
85
|
+
print(f" Reason: {cluster.reason or 'Grouped by deterministic repository analysis.'}")
|
|
86
|
+
for file_diff in cluster.files[:MAX_FILES_IN_PREVIEW]:
|
|
87
|
+
suffix = f" - {file_diff.summary}" if file_diff.summary else ""
|
|
88
|
+
print(f" {file_diff.status} {file_diff.file_path}{suffix}")
|
|
89
|
+
remaining = len(cluster.files) - MAX_FILES_IN_PREVIEW
|
|
90
|
+
if remaining > 0:
|
|
91
|
+
print(f" and {remaining} more")
|
|
92
|
+
print(f"\nSummary: {len(clusters)} commit(s) covering {total_files} file(s)\n")
|
|
93
|
+
|
|
94
|
+
|
smart_commit/preview.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Interactive preview UI for commits."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from .models import CommitCluster, FileDiff
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PreviewUI:
|
|
12
|
+
"""Interactive preview interface for reviewing commits."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
"""Initialize preview UI."""
|
|
16
|
+
self.console = Console()
|
|
17
|
+
|
|
18
|
+
def display_clusters(self, clusters: List[CommitCluster]) -> None:
|
|
19
|
+
"""Display all clusters for review.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
clusters: List of CommitCluster objects to display.
|
|
23
|
+
"""
|
|
24
|
+
for idx, cluster in enumerate(clusters, 1):
|
|
25
|
+
self.display_cluster(cluster, idx)
|
|
26
|
+
|
|
27
|
+
def display_cluster(self, cluster: CommitCluster, cluster_num: int) -> None:
|
|
28
|
+
"""Display a single cluster.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cluster: CommitCluster to display.
|
|
32
|
+
cluster_num: Cluster number for display.
|
|
33
|
+
"""
|
|
34
|
+
title = cluster.message or cluster.summary or "Untitled"
|
|
35
|
+
|
|
36
|
+
panel_content = f"[bold cyan]Cluster {cluster_num}[/bold cyan]\n"
|
|
37
|
+
panel_content += f"[yellow]{title}[/yellow]\n"
|
|
38
|
+
panel_content += f"Files: {len(cluster.files)}\n"
|
|
39
|
+
|
|
40
|
+
# File table
|
|
41
|
+
table = Table(show_header=False)
|
|
42
|
+
table.add_column("File", style="cyan")
|
|
43
|
+
table.add_column("Status", style="magenta")
|
|
44
|
+
|
|
45
|
+
for file_diff in cluster.files[:5]:
|
|
46
|
+
table.add_row(file_diff.file_path, file_diff.status)
|
|
47
|
+
|
|
48
|
+
if len(cluster.files) > 5:
|
|
49
|
+
table.add_row(f"... and {len(cluster.files) - 5} more", "")
|
|
50
|
+
|
|
51
|
+
self.console.print(Panel(panel_content + str(table)))
|
|
52
|
+
|
|
53
|
+
def get_cluster_actions(self, cluster_num: int) -> Optional[str]:
|
|
54
|
+
"""Get user actions for a cluster.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cluster_num: Cluster number.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
User action ('accept', 'edit', 'split', 'merge', 'skip', etc.)
|
|
61
|
+
"""
|
|
62
|
+
prompt = (
|
|
63
|
+
f"\n[cyan]Cluster {cluster_num}[/cyan]\n"
|
|
64
|
+
"[green][a][/green] Accept "
|
|
65
|
+
"[green][e][/green] Edit "
|
|
66
|
+
"[green][s][/green] Split "
|
|
67
|
+
"[green][m][/green] Merge "
|
|
68
|
+
"[green][d][/green] Discard "
|
|
69
|
+
"[green][q][/green] Quit\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
action = self.console.input(prompt).lower().strip()
|
|
73
|
+
|
|
74
|
+
if action in ['a', 'e', 's', 'm', 'd', 'q']:
|
|
75
|
+
return action
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def edit_cluster(self, cluster: CommitCluster) -> CommitCluster:
|
|
80
|
+
"""Allow user to edit a cluster.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
cluster: CommitCluster to edit.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Modified CommitCluster.
|
|
87
|
+
"""
|
|
88
|
+
self.console.print("[yellow]Cluster editing not yet implemented[/yellow]")
|
|
89
|
+
return cluster
|
|
90
|
+
|
|
91
|
+
def select_files_for_split(self, cluster: CommitCluster) -> tuple:
|
|
92
|
+
"""Allow user to select files for cluster split.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
cluster: CommitCluster to split.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Tuple of (files_for_cluster1, files_for_cluster2)
|
|
99
|
+
"""
|
|
100
|
+
self.console.print("[yellow]File selection for split not yet implemented[/yellow]")
|
|
101
|
+
return (cluster.files[:len(cluster.files)//2], cluster.files[len(cluster.files)//2:])
|
|
102
|
+
|
|
103
|
+
def display_summary(self, clusters: List[CommitCluster], total_changes: int) -> None:
|
|
104
|
+
"""Display summary of all commits.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
clusters: List of clusters to summarize.
|
|
108
|
+
total_changes: Total number of changed files.
|
|
109
|
+
"""
|
|
110
|
+
table = Table(title="Commit Summary")
|
|
111
|
+
table.add_column("Commit", style="cyan")
|
|
112
|
+
table.add_column("Type", style="magenta")
|
|
113
|
+
table.add_column("Files", style="green")
|
|
114
|
+
table.add_column("Message", style="yellow")
|
|
115
|
+
|
|
116
|
+
for idx, cluster in enumerate(clusters, 1):
|
|
117
|
+
message = cluster.message or cluster.summary or "Untitled"
|
|
118
|
+
commit_type = cluster.commit_type.value if cluster.commit_type else "chore"
|
|
119
|
+
table.add_row(
|
|
120
|
+
f"#{idx}",
|
|
121
|
+
commit_type,
|
|
122
|
+
str(len(cluster.files)),
|
|
123
|
+
message[:40]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.console.print(table)
|
|
127
|
+
self.console.print(
|
|
128
|
+
f"\n[bold]Summary:[/bold] "
|
|
129
|
+
f"{len(clusters)} commit(s) covering {total_changes} changed file(s)"
|
|
130
|
+
)
|
|
131
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Summarizer for converting diffs into concise summaries."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from .models import FileDiff
|
|
5
|
+
from .diff_parser import DiffParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Summarizer:
|
|
9
|
+
"""Converts raw diffs into concise, semantic summaries."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
"""Initialize the summarizer."""
|
|
13
|
+
self.diff_parser = DiffParser()
|
|
14
|
+
|
|
15
|
+
def summarize_file_diff(self, file_diff: FileDiff) -> str:
|
|
16
|
+
"""Generate a summary for a single file's diff.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
file_diff: FileDiff object.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Summary string.
|
|
23
|
+
"""
|
|
24
|
+
if file_diff.summary:
|
|
25
|
+
return file_diff.summary
|
|
26
|
+
|
|
27
|
+
# Use DiffParser to extract summary
|
|
28
|
+
summary = self.diff_parser.extract_summary(
|
|
29
|
+
file_diff.raw_diff,
|
|
30
|
+
file_diff.file_path
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return summary
|
|
34
|
+
|
|
35
|
+
def summarize_cluster(self, file_diffs: List[FileDiff]) -> str:
|
|
36
|
+
"""Generate a summary for a cluster of files.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_diffs: List of FileDiff objects.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Cluster summary.
|
|
43
|
+
"""
|
|
44
|
+
if not file_diffs:
|
|
45
|
+
return "No changes"
|
|
46
|
+
|
|
47
|
+
# Get individual summaries
|
|
48
|
+
summaries = []
|
|
49
|
+
for diff in file_diffs:
|
|
50
|
+
summary = self.summarize_file_diff(diff)
|
|
51
|
+
summaries.append(summary)
|
|
52
|
+
|
|
53
|
+
# Combine summaries
|
|
54
|
+
unique_summaries = list(set(summaries))
|
|
55
|
+
|
|
56
|
+
if len(unique_summaries) == 1:
|
|
57
|
+
return unique_summaries[0]
|
|
58
|
+
|
|
59
|
+
# Take top 3 summaries
|
|
60
|
+
return "; ".join(unique_summaries[:3])
|
|
61
|
+
|
|
62
|
+
def extract_keywords(self, file_diff: FileDiff) -> List[str]:
|
|
63
|
+
"""Extract keywords from a file diff.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
file_diff: FileDiff object.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of keywords.
|
|
70
|
+
"""
|
|
71
|
+
keywords = []
|
|
72
|
+
|
|
73
|
+
# Extract from file path
|
|
74
|
+
path_parts = file_diff.file_path.lower().split('/')
|
|
75
|
+
for part in path_parts:
|
|
76
|
+
if part and part not in ['src', 'lib', 'utils', 'test']:
|
|
77
|
+
keywords.append(part)
|
|
78
|
+
|
|
79
|
+
# Extract from diff content
|
|
80
|
+
changes = self.diff_parser.parse_diff(
|
|
81
|
+
file_diff.raw_diff,
|
|
82
|
+
file_diff.file_path
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for change in changes:
|
|
86
|
+
# Extract nouns and verbs
|
|
87
|
+
words = change.split()
|
|
88
|
+
for word in words:
|
|
89
|
+
if len(word) > 3 and word.lower() not in ['added', 'removed', 'modified']:
|
|
90
|
+
keywords.append(word.lower())
|
|
91
|
+
|
|
92
|
+
return list(set(keywords))
|
|
93
|
+
|
smart_commit/utils.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Utility functions for Smart Commit."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Any
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_project_root() -> Path:
|
|
13
|
+
"""Get the Smart Commit project root directory."""
|
|
14
|
+
return Path(__file__).parent.parent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_home_config_dir() -> Path:
|
|
18
|
+
"""Get the user's Smart Commit config directory."""
|
|
19
|
+
config_dir = Path.home() / ".smart_commit"
|
|
20
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return config_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_session_history_file(repo_path: Path) -> Path:
|
|
25
|
+
"""Get the session history file for a repository."""
|
|
26
|
+
config_dir = get_home_config_dir()
|
|
27
|
+
session_dir = config_dir / "sessions"
|
|
28
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# Create a hash-based filename from the repository path
|
|
31
|
+
import hashlib
|
|
32
|
+
repo_hash = hashlib.md5(str(repo_path.absolute()).encode()).hexdigest()[:8]
|
|
33
|
+
|
|
34
|
+
return session_dir / f"sessions_{repo_hash}.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_session_id() -> str:
|
|
38
|
+
"""Generate a unique session ID."""
|
|
39
|
+
return str(uuid.uuid4())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_timestamp() -> str:
|
|
43
|
+
"""Get current timestamp in ISO format."""
|
|
44
|
+
return datetime.now().isoformat()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_json_file(file_path: Path) -> Any:
|
|
48
|
+
"""Load JSON from file, return empty dict if not found."""
|
|
49
|
+
if not file_path.exists():
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(file_path) as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
except (json.JSONDecodeError, IOError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_json_file(file_path: Path, data: Any) -> None:
|
|
60
|
+
"""Save data to JSON file."""
|
|
61
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
with open(file_path, 'w') as f:
|
|
64
|
+
json.dump(data, f, indent=2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_bytes(num_bytes: int) -> str:
|
|
68
|
+
"""Format bytes to human-readable string."""
|
|
69
|
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
70
|
+
if num_bytes < 1024.0:
|
|
71
|
+
return f"{num_bytes:.1f}{unit}"
|
|
72
|
+
num_bytes /= 1024.0
|
|
73
|
+
|
|
74
|
+
return f"{num_bytes:.1f}TB"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def truncate_string(text: str, max_length: int = 100) -> str:
|
|
78
|
+
"""Truncate string to max length with ellipsis."""
|
|
79
|
+
if len(text) <= max_length:
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
return text[:max_length - 3] + "..."
|
|
83
|
+
|