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,182 @@
1
+ """Repository scanning using the Git CLI."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import subprocess
7
+
8
+ from ..constants import FileStatus
9
+ from ..exceptions import InvalidRepositoryError, NoChangesError
10
+ from ..models import FileDiff
11
+
12
+
13
+ @dataclass
14
+ class RepositorySnapshot:
15
+ """Snapshot of repository state."""
16
+
17
+ repo_path: Path
18
+ branch: str
19
+ modified_files: list[FileDiff]
20
+ staged_files: list[FileDiff]
21
+ untracked_files: list[FileDiff]
22
+ total_files_changed: int
23
+ total_additions: int
24
+ total_deletions: int
25
+
26
+ @property
27
+ def all_files(self) -> list[FileDiff]:
28
+ """Return changed files without duplicate paths."""
29
+ files_by_path: dict[str, FileDiff] = {}
30
+ for file_diff in [*self.staged_files, *self.modified_files, *self.untracked_files]:
31
+ existing = files_by_path.get(file_diff.file_path)
32
+ if existing:
33
+ existing.raw_diff = "\n".join(
34
+ part for part in [existing.raw_diff, file_diff.raw_diff] if part
35
+ )
36
+ existing.additions += file_diff.additions
37
+ existing.deletions += file_diff.deletions
38
+ else:
39
+ files_by_path[file_diff.file_path] = file_diff
40
+ return list(files_by_path.values())
41
+
42
+
43
+ class RepositoryScanner:
44
+ """Scans a Git repository for staged, unstaged, and untracked changes."""
45
+
46
+ def __init__(self, repo_path: Optional[Path] = None) -> None:
47
+ self.repo_path = self._resolve_repo_path(repo_path or Path.cwd())
48
+
49
+ def scan(self) -> RepositorySnapshot:
50
+ """Scan repository and return a snapshot."""
51
+ staged_files = self._changed_files(staged=True)
52
+ modified_files = self._changed_files(staged=False)
53
+ untracked_files = self._untracked_files()
54
+
55
+ snapshot = RepositorySnapshot(
56
+ repo_path=self.repo_path,
57
+ branch=self._branch_name(),
58
+ modified_files=modified_files,
59
+ staged_files=staged_files,
60
+ untracked_files=untracked_files,
61
+ total_files_changed=0,
62
+ total_additions=0,
63
+ total_deletions=0,
64
+ )
65
+ all_files = snapshot.all_files
66
+ if not all_files:
67
+ raise NoChangesError("No changes found in repository")
68
+
69
+ snapshot.total_files_changed = len(all_files)
70
+ snapshot.total_additions = sum(file_diff.additions for file_diff in all_files)
71
+ snapshot.total_deletions = sum(file_diff.deletions for file_diff in all_files)
72
+ return snapshot
73
+
74
+ def _changed_files(self, staged: bool) -> list[FileDiff]:
75
+ names_args = ["diff", "--cached", "--name-status", "--find-renames"] if staged else ["diff", "--name-status", "--find-renames"]
76
+ output = self._git(*names_args)
77
+ file_diffs = []
78
+ for line in output.splitlines():
79
+ if not line.strip():
80
+ continue
81
+ parts = line.split("\t")
82
+ status_code = parts[0][0]
83
+
84
+ if status_code in ('R', 'C') and len(parts) >= 3:
85
+ old_path = parts[1]
86
+ path = parts[2]
87
+ is_renamed = True
88
+ else:
89
+ old_path = None
90
+ path = parts[-1]
91
+ is_renamed = False
92
+
93
+ diff_args = ["diff", "--cached", "--find-renames", "--", path] if staged else ["diff", "--find-renames", "--", path]
94
+ raw_diff = self._git(*diff_args)
95
+ additions, deletions = self._count_changed_lines(raw_diff)
96
+ file_diffs.append(
97
+ FileDiff(
98
+ file_path=path,
99
+ status=self._normalize_status(status_code),
100
+ additions=additions,
101
+ deletions=deletions,
102
+ raw_diff=raw_diff,
103
+ old_file_path=old_path,
104
+ is_renamed=is_renamed,
105
+ )
106
+ )
107
+ return file_diffs
108
+
109
+ def _untracked_files(self) -> list[FileDiff]:
110
+ output = self._git("ls-files", "--others", "--exclude-standard")
111
+ file_diffs = []
112
+ for path in output.splitlines():
113
+ absolute_path = self.repo_path / path
114
+ try:
115
+ content = absolute_path.read_text(encoding="utf-8", errors="ignore")
116
+ except OSError:
117
+ content = ""
118
+ raw_diff = "\n".join(f"+{line}" for line in content.splitlines())
119
+ file_diffs.append(
120
+ FileDiff(
121
+ file_path=path,
122
+ status=FileStatus.UNTRACKED.value,
123
+ additions=len(content.splitlines()) if content else 0,
124
+ deletions=0,
125
+ raw_diff=raw_diff,
126
+ )
127
+ )
128
+ return file_diffs
129
+
130
+ def _branch_name(self) -> str:
131
+ branch = self._git("branch", "--show-current").strip()
132
+ if branch:
133
+ return branch
134
+ return self._git("rev-parse", "--short", "HEAD").strip()
135
+
136
+ def _git(self, *args: str) -> str:
137
+ result = subprocess.run(
138
+ ["git", *args],
139
+ cwd=self.repo_path,
140
+ text=True,
141
+ capture_output=True,
142
+ check=False,
143
+ )
144
+ if result.returncode != 0:
145
+ raise InvalidRepositoryError(result.stderr.strip() or "Git command failed")
146
+ return result.stdout
147
+
148
+ @staticmethod
149
+ def _resolve_repo_path(path: Path) -> Path:
150
+ result = subprocess.run(
151
+ ["git", "rev-parse", "--show-toplevel"],
152
+ cwd=path,
153
+ text=True,
154
+ capture_output=True,
155
+ check=False,
156
+ )
157
+ if result.returncode != 0:
158
+ raise InvalidRepositoryError(f"Not a git repository: {path.resolve()}")
159
+ return Path(result.stdout.strip()).resolve()
160
+
161
+ @staticmethod
162
+ def _count_changed_lines(diff_text: str) -> tuple[int, int]:
163
+ additions = 0
164
+ deletions = 0
165
+ for line in diff_text.splitlines():
166
+ if line.startswith(("+++", "---")):
167
+ continue
168
+ if line.startswith("+"):
169
+ additions += 1
170
+ elif line.startswith("-"):
171
+ deletions += 1
172
+ return additions, deletions
173
+
174
+ @staticmethod
175
+ def _normalize_status(status: str) -> str:
176
+ return {
177
+ "A": FileStatus.ADDED.value,
178
+ "M": FileStatus.MODIFIED.value,
179
+ "D": FileStatus.DELETED.value,
180
+ "R": FileStatus.RENAMED.value,
181
+ "C": FileStatus.COPIED.value,
182
+ }.get(status, FileStatus.MODIFIED.value)
@@ -0,0 +1,129 @@
1
+ """Git service backed by the Git CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import subprocess
6
+
7
+ from ..logger import get_logger
8
+ from ..models import SessionInfo
9
+ from ..utils import (
10
+ generate_session_id,
11
+ get_session_history_file,
12
+ get_timestamp,
13
+ load_json_file,
14
+ save_json_file,
15
+ )
16
+ from .scanner import RepositoryScanner
17
+
18
+ logger = get_logger("git.service")
19
+
20
+
21
+ class GitService:
22
+ """Git operations used by Smart Commit execution."""
23
+
24
+ def __init__(self, repo_path: Optional[Path] = None) -> None:
25
+ self.repo_path = RepositoryScanner(repo_path).repo_path
26
+
27
+ def stage_files(self, file_paths: list[str]) -> None:
28
+ """Stage specific files."""
29
+ if file_paths:
30
+ self._git("add", "--", *file_paths)
31
+ logger.debug("Staged %s file(s)", len(file_paths))
32
+
33
+ def commit(self, message: str) -> str:
34
+ """Create a commit and return its hash."""
35
+ self._git("commit", "-m", message)
36
+ commit_hash = self._git("rev-parse", "HEAD").strip()
37
+ logger.info("Created commit: %s", commit_hash[:7])
38
+ return commit_hash
39
+
40
+ def current_branch(self) -> str:
41
+ """Return current branch or detached HEAD short hash."""
42
+ branch = self._git("branch", "--show-current").strip()
43
+ return branch or self._git("rev-parse", "--short", "HEAD").strip()
44
+
45
+ def get_commit_log(self, count: int = 10) -> list[dict]:
46
+ """Get recent commits."""
47
+ output = self._git(
48
+ "log",
49
+ f"--max-count={count}",
50
+ "--pretty=format:%h%x09%s%x09%an%x09%aI",
51
+ )
52
+ commits = []
53
+ for line in output.splitlines():
54
+ commit_hash, message, author, date = line.split("\t", 3)
55
+ commits.append(
56
+ {"hash": commit_hash, "message": message, "author": author, "date": date}
57
+ )
58
+ return commits
59
+
60
+ def reset_soft(self, num_commits: int = 1) -> None:
61
+ """Soft reset N commits."""
62
+ self._git("reset", "--soft", f"HEAD~{num_commits}")
63
+ logger.info("Soft reset HEAD~%s", num_commits)
64
+
65
+ def save_session(self, session: SessionInfo) -> None:
66
+ """Save session for undo support."""
67
+ session_file = get_session_history_file(self.repo_path)
68
+ sessions_data = load_json_file(session_file)
69
+ sessions = sessions_data.setdefault("sessions", [])
70
+ sessions.append(
71
+ {
72
+ "id": session.session_id or generate_session_id(),
73
+ "commit_hashes": session.commit_hashes,
74
+ "timestamp": session.timestamp or get_timestamp(),
75
+ "branch": session.branch,
76
+ "repository_path": session.repository_path,
77
+ }
78
+ )
79
+ sessions_data["sessions"] = sessions[-50:]
80
+ save_json_file(session_file, sessions_data)
81
+
82
+ def get_last_session(self) -> Optional[SessionInfo]:
83
+ """Return the latest Smart Commit session for this repository."""
84
+ session_file = get_session_history_file(self.repo_path)
85
+ sessions_data = load_json_file(session_file)
86
+ sessions = sessions_data.get("sessions") or []
87
+ if not sessions:
88
+ return None
89
+ data = sessions[-1]
90
+ return SessionInfo(
91
+ session_id=data["id"],
92
+ commit_hashes=data["commit_hashes"],
93
+ timestamp=data["timestamp"],
94
+ branch=data["branch"],
95
+ repository_path=data["repository_path"],
96
+ )
97
+
98
+ def undo_last_session(self) -> bool:
99
+ """Undo the last Smart Commit session."""
100
+ session = self.get_last_session()
101
+ if not session or not session.commit_hashes:
102
+ return False
103
+
104
+ try:
105
+ self.reset_soft(len(session.commit_hashes))
106
+ self._remove_last_session()
107
+ return True
108
+ except RuntimeError as exc:
109
+ logger.error("Failed to undo: %s", exc)
110
+ return False
111
+
112
+ def _remove_last_session(self) -> None:
113
+ session_file = get_session_history_file(self.repo_path)
114
+ sessions_data = load_json_file(session_file)
115
+ sessions = sessions_data.get("sessions") or []
116
+ sessions_data["sessions"] = sessions[:-1]
117
+ save_json_file(session_file, sessions_data)
118
+
119
+ def _git(self, *args: str) -> str:
120
+ result = subprocess.run(
121
+ ["git", *args],
122
+ cwd=self.repo_path,
123
+ text=True,
124
+ capture_output=True,
125
+ check=False,
126
+ )
127
+ if result.returncode != 0:
128
+ raise RuntimeError(result.stderr.strip() or "Git command failed")
129
+ return result.stdout
@@ -0,0 +1,302 @@
1
+ """Git service for repository operations."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, List, Set
5
+ import git
6
+ from git.exc import InvalidGitRepositoryError, GitCommandError
7
+
8
+ from .models import FileDiff, SessionInfo
9
+ from .utils import generate_session_id, get_timestamp, get_session_history_file, load_json_file, save_json_file
10
+
11
+
12
+ class GitService:
13
+ """Handles all Git operations."""
14
+
15
+ def __init__(self, repo_path: Optional[Path] = None):
16
+ """Initialize GitService.
17
+
18
+ Args:
19
+ repo_path: Path to git repository. If None, uses current directory.
20
+
21
+ Raises:
22
+ InvalidGitRepositoryError: If no valid git repository found.
23
+ """
24
+ if repo_path is None:
25
+ repo_path = Path.cwd()
26
+
27
+ self.repo_path = Path(repo_path).resolve()
28
+
29
+ try:
30
+ self.repo = git.Repo(self.repo_path)
31
+ except InvalidGitRepositoryError:
32
+ raise InvalidGitRepositoryError(
33
+ f"No git repository found at {self.repo_path}"
34
+ )
35
+
36
+ def discover_repository(self) -> Path:
37
+ """Discover and return the repository root path.
38
+
39
+ Returns:
40
+ Path to repository root.
41
+ """
42
+ return Path(self.repo.working_dir)
43
+
44
+ def get_changed_files(self) -> List[FileDiff]:
45
+ """Get all changed files in the working directory.
46
+
47
+ Returns:
48
+ List of FileDiff objects for changed files.
49
+ """
50
+ changed_files = []
51
+
52
+ # Get all modified and untracked files
53
+ for item in self.repo.index.diff(None):
54
+ file_diff = self._diff_index_to_filediff(item)
55
+ changed_files.append(file_diff)
56
+
57
+ # Add untracked files
58
+ for untracked in self.repo.untracked_files:
59
+ changed_files.append(
60
+ FileDiff(
61
+ file_path=untracked,
62
+ status="A", # Treat as added
63
+ additions=0,
64
+ deletions=0,
65
+ raw_diff="",
66
+ summary="Untracked file"
67
+ )
68
+ )
69
+
70
+ return changed_files
71
+
72
+ def get_staged_files(self) -> List[FileDiff]:
73
+ """Get all staged files.
74
+
75
+ Returns:
76
+ List of FileDiff objects for staged files.
77
+ """
78
+ staged_files = []
79
+
80
+ # Get staged changes
81
+ for item in self.repo.index.diff("HEAD"):
82
+ file_diff = self._diff_index_to_filediff(item)
83
+ staged_files.append(file_diff)
84
+
85
+ return staged_files
86
+
87
+ def get_diff(self, file_path: str) -> str:
88
+ """Get the full diff for a specific file.
89
+
90
+ Args:
91
+ file_path: Path to the file.
92
+
93
+ Returns:
94
+ Raw diff content.
95
+ """
96
+ try:
97
+ diff = self.repo.git.diff(file_path)
98
+ return diff
99
+ except GitCommandError as e:
100
+ return ""
101
+
102
+ def stage_files(self, file_paths: List[str]) -> None:
103
+ """Stage specific files for commit.
104
+
105
+ Args:
106
+ file_paths: List of file paths to stage.
107
+
108
+ Raises:
109
+ GitCommandError: If staging fails.
110
+ """
111
+ if not file_paths:
112
+ return
113
+
114
+ self.repo.index.add(file_paths)
115
+
116
+ def unstage_files(self, file_paths: List[str]) -> None:
117
+ """Unstage specific files.
118
+
119
+ Args:
120
+ file_paths: List of file paths to unstage.
121
+ """
122
+ if not file_paths:
123
+ return
124
+
125
+ self.repo.index.reset(paths=file_paths)
126
+
127
+ def commit(self, message: str, author_name: Optional[str] = None,
128
+ author_email: Optional[str] = None) -> str:
129
+ """Create a commit with staged changes.
130
+
131
+ Args:
132
+ message: Commit message.
133
+ author_name: Author name (uses git config if None).
134
+ author_email: Author email (uses git config if None).
135
+
136
+ Returns:
137
+ Commit hash.
138
+
139
+ Raises:
140
+ GitCommandError: If commit fails.
141
+ """
142
+ # Get author from git config if not provided
143
+ if author_name is None:
144
+ author_name = self.repo.config_reader().get_value("user", "name")
145
+ if author_email is None:
146
+ author_email = self.repo.config_reader().get_value("user", "email")
147
+
148
+ from git import Actor
149
+
150
+ actor = Actor(author_name, author_email) if author_name and author_email else None
151
+
152
+ # Create commit
153
+ if actor:
154
+ commit = self.repo.index.commit(message, author=actor)
155
+ else:
156
+ commit = self.repo.index.commit(message)
157
+
158
+ return commit.hexsha
159
+
160
+ def current_branch(self) -> str:
161
+ """Get current branch name.
162
+
163
+ Returns:
164
+ Current branch name.
165
+ """
166
+ return self.repo.active_branch.name
167
+
168
+ def get_commit_log(self, count: int = 10) -> List[dict]:
169
+ """Get recent commit log.
170
+
171
+ Args:
172
+ count: Number of commits to retrieve.
173
+
174
+ Returns:
175
+ List of commit info dictionaries.
176
+ """
177
+ commits = []
178
+
179
+ for commit in self.repo.iter_commits(max_count=count):
180
+ commits.append({
181
+ "hash": commit.hexsha[:7],
182
+ "message": commit.message.split('\n')[0],
183
+ "author": commit.author.name,
184
+ "date": commit.committed_datetime.isoformat()
185
+ })
186
+
187
+ return commits
188
+
189
+ def reset_soft(self, num_commits: int = 1) -> None:
190
+ """Reset HEAD to N commits back (soft reset, keeps changes staged).
191
+
192
+ Args:
193
+ num_commits: Number of commits to reset.
194
+
195
+ Raises:
196
+ GitCommandError: If reset fails.
197
+ """
198
+ self.repo.git.reset("--soft", f"HEAD~{num_commits}")
199
+
200
+ def save_session(self, session: SessionInfo) -> None:
201
+ """Save session info for undo support.
202
+
203
+ Args:
204
+ session: SessionInfo object to save.
205
+ """
206
+ session_file = get_session_history_file(self.repo_path)
207
+
208
+ # Load existing sessions
209
+ sessions_data = load_json_file(session_file)
210
+ if "sessions" not in sessions_data:
211
+ sessions_data["sessions"] = []
212
+
213
+ # Add new session
214
+ sessions_data["sessions"].append({
215
+ "id": session.session_id,
216
+ "commit_hashes": session.commit_hashes,
217
+ "timestamp": session.timestamp,
218
+ "branch": session.branch,
219
+ "repository_path": session.repository_path
220
+ })
221
+
222
+ # Keep only last 50 sessions
223
+ sessions_data["sessions"] = sessions_data["sessions"][-50:]
224
+
225
+ save_json_file(session_file, sessions_data)
226
+
227
+ def get_last_session(self) -> Optional[SessionInfo]:
228
+ """Get the last Smart Commit session.
229
+
230
+ Returns:
231
+ SessionInfo object or None if no sessions found.
232
+ """
233
+ session_file = get_session_history_file(self.repo_path)
234
+ sessions_data = load_json_file(session_file)
235
+
236
+ if not sessions_data.get("sessions"):
237
+ return None
238
+
239
+ last_session_data = sessions_data["sessions"][-1]
240
+
241
+ return SessionInfo(
242
+ session_id=last_session_data["id"],
243
+ commit_hashes=last_session_data["commit_hashes"],
244
+ timestamp=last_session_data["timestamp"],
245
+ branch=last_session_data["branch"],
246
+ repository_path=last_session_data["repository_path"]
247
+ )
248
+
249
+ def undo_last_session(self) -> bool:
250
+ """Undo the last Smart Commit session.
251
+
252
+ Returns:
253
+ True if undo was successful, False otherwise.
254
+ """
255
+ session = self.get_last_session()
256
+
257
+ if not session or not session.commit_hashes:
258
+ return False
259
+
260
+ num_commits = len(session.commit_hashes)
261
+
262
+ try:
263
+ self.reset_soft(num_commits)
264
+ return True
265
+ except GitCommandError:
266
+ return False
267
+
268
+ @staticmethod
269
+ def _diff_index_to_filediff(item) -> FileDiff:
270
+ """Convert git diff item to FileDiff object.
271
+
272
+ Args:
273
+ item: Git diff item.
274
+
275
+ Returns:
276
+ FileDiff object.
277
+ """
278
+ # Determine file status
279
+ if item.deleted_file:
280
+ status = "D"
281
+ elif item.new_file:
282
+ status = "A"
283
+ elif item.renamed_file:
284
+ status = "R"
285
+ else:
286
+ status = "M"
287
+
288
+ file_path = item.a_path or item.b_path
289
+
290
+ # Count lines added/deleted
291
+ diff_text = item.diff.decode('utf-8', errors='ignore') if isinstance(item.diff, bytes) else item.diff
292
+ additions = diff_text.count('\n+')
293
+ deletions = diff_text.count('\n-')
294
+
295
+ return FileDiff(
296
+ file_path=file_path,
297
+ status=status,
298
+ additions=additions,
299
+ deletions=deletions,
300
+ raw_diff=diff_text
301
+ )
302
+