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,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
|
+
|