smart-commit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. smart_commit/__init__.py +6 -0
  2. smart_commit/ai/__init__.py +7 -0
  3. smart_commit/ai/client.py +123 -0
  4. smart_commit/ai/reviewer.py +91 -0
  5. smart_commit/ai.py +136 -0
  6. smart_commit/analysis/__init__.py +7 -0
  7. smart_commit/analysis/ast_parser.py +173 -0
  8. smart_commit/analysis/dependency_graph.py +114 -0
  9. smart_commit/analysis/diff_parser.py +172 -0
  10. smart_commit/analysis/summarizer.py +101 -0
  11. smart_commit/analysis/symbol_extractor.py +88 -0
  12. smart_commit/benchmark.py +88 -0
  13. smart_commit/cache.py +65 -0
  14. smart_commit/cli.py +391 -0
  15. smart_commit/clustering/__init__.py +7 -0
  16. smart_commit/clustering/graph_clustering.py +78 -0
  17. smart_commit/clustering/heuristic.py +178 -0
  18. smart_commit/clustering/semantic.py +201 -0
  19. smart_commit/clustering.py +186 -0
  20. smart_commit/commit_messages.py +232 -0
  21. smart_commit/committer.py +91 -0
  22. smart_commit/config.py +88 -0
  23. smart_commit/constants.py +92 -0
  24. smart_commit/diff_parser.py +158 -0
  25. smart_commit/embeddings/__init__.py +7 -0
  26. smart_commit/embeddings/generator.py +128 -0
  27. smart_commit/embeddings/similarity.py +159 -0
  28. smart_commit/embeddings.py +108 -0
  29. smart_commit/exceptions.py +102 -0
  30. smart_commit/execution/__init__.py +6 -0
  31. smart_commit/execution/committer.py +102 -0
  32. smart_commit/git/__init__.py +8 -0
  33. smart_commit/git/safety.py +75 -0
  34. smart_commit/git/scanner.py +182 -0
  35. smart_commit/git/service.py +129 -0
  36. smart_commit/git_service.py +302 -0
  37. smart_commit/logger.py +99 -0
  38. smart_commit/models.py +73 -0
  39. smart_commit/plugins/__init__.py +6 -0
  40. smart_commit/plugins/base.py +135 -0
  41. smart_commit/plugins/django.py +40 -0
  42. smart_commit/plugins/fastapi.py +39 -0
  43. smart_commit/plugins/react.py +49 -0
  44. smart_commit/preview/__init__.py +6 -0
  45. smart_commit/preview/editor.py +224 -0
  46. smart_commit/preview/renderer.py +94 -0
  47. smart_commit/preview.py +131 -0
  48. smart_commit/summarizer.py +93 -0
  49. smart_commit/utils.py +83 -0
  50. smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
  51. smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
  52. smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
  53. smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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
+
@@ -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
+