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/logger.py ADDED
@@ -0,0 +1,99 @@
1
+ """Logging configuration for Smart Commit."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from datetime import datetime
8
+
9
+ from .constants import CONFIG_DIR
10
+
11
+
12
+ class SmartCommitFormatter(logging.Formatter):
13
+ """Custom formatter for Smart Commit logs."""
14
+
15
+ COLORS = {
16
+ 'DEBUG': '\033[36m', # Cyan
17
+ 'INFO': '\033[32m', # Green
18
+ 'WARNING': '\033[33m', # Yellow
19
+ 'ERROR': '\033[31m', # Red
20
+ 'CRITICAL': '\033[41m', # Red background
21
+ }
22
+ RESET = '\033[0m'
23
+
24
+ def format(self, record):
25
+ """Format log record with colors."""
26
+ if sys.stderr.isatty():
27
+ levelname = record.levelname
28
+ color = self.COLORS.get(levelname, '')
29
+ record.levelname = f"{color}{levelname}{self.RESET}"
30
+
31
+ return super().format(record)
32
+
33
+
34
+ def setup_logging(verbose: bool = False, log_file: Optional[Path] = None) -> logging.Logger:
35
+ """Set up logging configuration.
36
+
37
+ Args:
38
+ verbose: Enable verbose logging.
39
+ log_file: Optional log file path.
40
+
41
+ Returns:
42
+ Configured logger.
43
+ """
44
+ logger = logging.getLogger("smart_commit")
45
+
46
+ # Clear existing handlers
47
+ logger.handlers.clear()
48
+
49
+ # Set level
50
+ level = logging.DEBUG if verbose else logging.INFO
51
+ logger.setLevel(level)
52
+
53
+ # Console handler
54
+ console_handler = logging.StreamHandler(sys.stderr)
55
+ console_handler.setLevel(level)
56
+
57
+ formatter = SmartCommitFormatter(
58
+ '%(levelname)s: %(message)s'
59
+ )
60
+ console_handler.setFormatter(formatter)
61
+ logger.addHandler(console_handler)
62
+
63
+ # File handler (optional)
64
+ if log_file:
65
+ log_file.parent.mkdir(parents=True, exist_ok=True)
66
+ file_handler = logging.FileHandler(log_file)
67
+ file_handler.setLevel(logging.DEBUG)
68
+
69
+ file_formatter = logging.Formatter(
70
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
71
+ )
72
+ file_handler.setFormatter(file_formatter)
73
+ logger.addHandler(file_handler)
74
+
75
+ return logger
76
+
77
+
78
+ def get_logger(name: str) -> logging.Logger:
79
+ """Get a logger instance.
80
+
81
+ Args:
82
+ name: Logger name.
83
+
84
+ Returns:
85
+ Logger instance.
86
+ """
87
+ return logging.getLogger(f"smart_commit.{name}")
88
+
89
+
90
+ def get_session_log_file() -> Path:
91
+ """Get the session log file path.
92
+
93
+ Returns:
94
+ Path to session log file.
95
+ """
96
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
97
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
98
+ return CONFIG_DIR / "logs" / f"session_{timestamp}.log"
99
+
smart_commit/models.py ADDED
@@ -0,0 +1,73 @@
1
+ from enum import Enum
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+
6
+ class CommitType(str, Enum):
7
+ """Conventional Commit types."""
8
+ FEAT = "feat"
9
+ FIX = "fix"
10
+ DOCS = "docs"
11
+ STYLE = "style"
12
+ REFACTOR = "refactor"
13
+ PERF = "perf"
14
+ TEST = "test"
15
+ CHORE = "chore"
16
+ CI = "ci"
17
+ BUILD = "build"
18
+
19
+
20
+ @dataclass
21
+ class SymbolExtraction:
22
+ """Structured symbols extracted from a file."""
23
+ added: list[str] = field(default_factory=list)
24
+ removed: list[str] = field(default_factory=list)
25
+ modified: list[str] = field(default_factory=list)
26
+ imported: list[str] = field(default_factory=list)
27
+ exported: list[str] = field(default_factory=list)
28
+ classes: list[str] = field(default_factory=list)
29
+ functions: list[str] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class FileDiff:
34
+ """Represents a single file's diff."""
35
+
36
+ file_path: str
37
+ status: str
38
+ additions: int
39
+ deletions: int
40
+ raw_diff: str
41
+ old_file_path: Optional[str] = None
42
+ is_renamed: bool = False
43
+ summary: Optional[str] = None
44
+ symbols: list[str] = field(default_factory=list) # Deprecated, keep for backwards compat
45
+ imports: list[str] = field(default_factory=list) # Deprecated
46
+ extracted_symbols: SymbolExtraction = field(default_factory=SymbolExtraction)
47
+
48
+
49
+ @dataclass
50
+ class CommitCluster:
51
+ """Represents a group of related file changes."""
52
+
53
+ id: str
54
+ files: list[FileDiff] = field(default_factory=list)
55
+ summary: Optional[str] = None
56
+ embedding: Optional[list[float]] = None
57
+ commit_type: Optional[CommitType] = None
58
+ scope: Optional[str] = None
59
+ message: Optional[str] = None
60
+ description: Optional[str] = None
61
+ confidence: float = 0.5
62
+ reason: str = ""
63
+
64
+
65
+ @dataclass
66
+ class SessionInfo:
67
+ """Session tracking for undo support."""
68
+
69
+ session_id: str
70
+ commit_hashes: list[str] = field(default_factory=list)
71
+ timestamp: str = ""
72
+ branch: str = ""
73
+ repository_path: str = ""
@@ -0,0 +1,6 @@
1
+ """Plugin system package."""
2
+
3
+ from .base import Plugin, PluginRegistry
4
+
5
+ __all__ = ["Plugin", "PluginRegistry"]
6
+
@@ -0,0 +1,135 @@
1
+ """Plugin base classes and registry."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Dict, Optional, Set
5
+ from pathlib import Path
6
+
7
+ from ..models import FileDiff, CommitCluster
8
+ from ..logger import get_logger
9
+
10
+ logger = get_logger("plugins.base")
11
+
12
+
13
+ class Plugin(ABC):
14
+ """Base class for Smart Commit plugins."""
15
+
16
+ name: str = "BasePlugin"
17
+ version: str = "0.1.0"
18
+ description: str = "Base plugin"
19
+
20
+ @abstractmethod
21
+ def get_ignored_files(self) -> List[str]:
22
+ """Get list of files to ignore.
23
+
24
+ Returns:
25
+ List of glob patterns.
26
+ """
27
+ pass
28
+
29
+ @abstractmethod
30
+ def get_commit_hints(self, file_diffs: List[FileDiff]) -> Dict[str, str]:
31
+ """Get hints for clustering based on framework knowledge.
32
+
33
+ Args:
34
+ file_diffs: List of changed files.
35
+
36
+ Returns:
37
+ Dict of file_path -> hint strings.
38
+ """
39
+ pass
40
+
41
+ def parse_symbols(self, file_path: str, content: str) -> List[str]:
42
+ """Parse symbols from file content.
43
+
44
+ Args:
45
+ file_path: Path to file.
46
+ content: File content.
47
+
48
+ Returns:
49
+ List of symbol names.
50
+ """
51
+ return []
52
+
53
+ def should_group_files(self, file1: str, file2: str) -> Optional[bool]:
54
+ """Determine if two files should be grouped together.
55
+
56
+ Args:
57
+ file1: First file path.
58
+ file2: Second file path.
59
+
60
+ Returns:
61
+ True to group, False to separate, None to let other logic decide.
62
+ """
63
+ return None
64
+
65
+
66
+ class PluginRegistry:
67
+ """Registry for managing plugins."""
68
+
69
+ def __init__(self):
70
+ """Initialize plugin registry."""
71
+ self.plugins: Dict[str, Plugin] = {}
72
+ self._register_default_plugins()
73
+
74
+ def _register_default_plugins(self):
75
+ """Register built-in plugins."""
76
+ try:
77
+ from .react import ReactPlugin
78
+ self.register(ReactPlugin())
79
+ except ImportError:
80
+ logger.debug("Failed to load ReactPlugin")
81
+
82
+ try:
83
+ from .django import DjangoPlugin
84
+ self.register(DjangoPlugin())
85
+ except ImportError:
86
+ logger.debug("Failed to load DjangoPlugin")
87
+
88
+ try:
89
+ from .fastapi import FastAPIPlugin
90
+ self.register(FastAPIPlugin())
91
+ except ImportError:
92
+ logger.debug("Failed to load FastAPIPlugin")
93
+
94
+ logger.debug("Default plugins registered")
95
+
96
+ def register(self, plugin: Plugin):
97
+ """Register a plugin.
98
+
99
+ Args:
100
+ plugin: Plugin instance.
101
+ """
102
+ self.plugins[plugin.name.lower()] = plugin
103
+ logger.info(f"Plugin registered: {plugin.name}")
104
+
105
+ def get_plugin(self, name: str) -> Optional[Plugin]:
106
+ """Get plugin by name.
107
+
108
+ Args:
109
+ name: Plugin name.
110
+
111
+ Returns:
112
+ Plugin instance or None.
113
+ """
114
+ return self.plugins.get(name.lower())
115
+
116
+ def list_plugins(self) -> List[str]:
117
+ """List all registered plugins.
118
+
119
+ Returns:
120
+ List of plugin names.
121
+ """
122
+ return list(self.plugins.keys())
123
+
124
+ def get_all_ignored_files(self) -> Set[str]:
125
+ """Get all ignored files from all plugins.
126
+
127
+ Returns:
128
+ Set of glob patterns.
129
+ """
130
+ all_ignored = set()
131
+
132
+ for plugin in self.plugins.values():
133
+ all_ignored.update(plugin.get_ignored_files())
134
+
135
+ return all_ignored
@@ -0,0 +1,40 @@
1
+ """Django plugin for Smart Commit."""
2
+
3
+ from typing import List, Dict
4
+ from .base import Plugin
5
+ from ..models import FileDiff
6
+
7
+ class DjangoPlugin(Plugin):
8
+ """Plugin for Django projects."""
9
+
10
+ name = "Django"
11
+ version = "0.1.0"
12
+ description = "Django models, views, and migrations grouping"
13
+
14
+ def get_ignored_files(self) -> List[str]:
15
+ """Ignore common Django files."""
16
+ return [
17
+ "venv/**",
18
+ ".venv/**",
19
+ "__pycache__/**",
20
+ "*.pyc",
21
+ "db.sqlite3"
22
+ ]
23
+
24
+ def get_commit_hints(self, file_diffs: List[FileDiff]) -> Dict[str, str]:
25
+ """Provide Django-specific hints."""
26
+ hints = {}
27
+
28
+ for file_diff in file_diffs:
29
+ path = file_diff.file_path.lower()
30
+
31
+ if "migrations" in path:
32
+ hints[file_diff.file_path] = "migrations"
33
+ elif "models.py" in path:
34
+ hints[file_diff.file_path] = "models"
35
+ elif "views.py" in path:
36
+ hints[file_diff.file_path] = "views"
37
+ elif "tests.py" in path:
38
+ hints[file_diff.file_path] = "tests"
39
+
40
+ return hints
@@ -0,0 +1,39 @@
1
+ """FastAPI plugin for Smart Commit."""
2
+
3
+ from typing import List, Dict
4
+ from .base import Plugin
5
+ from ..models import FileDiff
6
+
7
+ class FastAPIPlugin(Plugin):
8
+ """Plugin for FastAPI projects."""
9
+
10
+ name = "FastAPI"
11
+ version = "0.1.0"
12
+ description = "FastAPI routers, dependencies, and schemas grouping"
13
+
14
+ def get_ignored_files(self) -> List[str]:
15
+ """Ignore common Python files."""
16
+ return [
17
+ "venv/**",
18
+ ".venv/**",
19
+ "__pycache__/**",
20
+ "*.pyc"
21
+ ]
22
+
23
+ def get_commit_hints(self, file_diffs: List[FileDiff]) -> Dict[str, str]:
24
+ """Provide FastAPI-specific hints."""
25
+ hints = {}
26
+
27
+ for file_diff in file_diffs:
28
+ path = file_diff.file_path.lower()
29
+
30
+ if "router" in path or "api" in path:
31
+ hints[file_diff.file_path] = "routers"
32
+ elif "schemas.py" in path or "models.py" in path:
33
+ hints[file_diff.file_path] = "schemas"
34
+ elif "dependencies.py" in path or "deps.py" in path:
35
+ hints[file_diff.file_path] = "dependencies"
36
+ elif "services.py" in path:
37
+ hints[file_diff.file_path] = "services"
38
+
39
+ return hints
@@ -0,0 +1,49 @@
1
+ """React plugin for Smart Commit."""
2
+
3
+ from typing import List, Dict, Optional
4
+ from .base import Plugin
5
+ from ..models import FileDiff
6
+
7
+ class ReactPlugin(Plugin):
8
+ """Plugin for React/JSX projects."""
9
+
10
+ name = "React"
11
+ version = "0.1.0"
12
+ description = "React component and style grouping"
13
+
14
+ def get_ignored_files(self) -> List[str]:
15
+ """Ignore common React build files."""
16
+ return [
17
+ "node_modules/**",
18
+ "build/**",
19
+ "dist/**",
20
+ ".next/**",
21
+ "coverage/**"
22
+ ]
23
+
24
+ def get_commit_hints(self, file_diffs: List[FileDiff]) -> Dict[str, str]:
25
+ """Provide React-specific hints."""
26
+ hints = {}
27
+
28
+ for file_diff in file_diffs:
29
+ path = file_diff.file_path.lower()
30
+
31
+ if ".test." in path or ".spec." in path:
32
+ hints[file_diff.file_path] = "test"
33
+ elif ".module.css" in path or ".module.scss" in path:
34
+ hints[file_diff.file_path] = "styles"
35
+ elif "component" in path or path.endswith(".jsx") or path.endswith(".tsx"):
36
+ hints[file_diff.file_path] = "component"
37
+
38
+ return hints
39
+
40
+ def should_group_files(self, file1: str, file2: str) -> Optional[bool]:
41
+ """Group component with its styles."""
42
+ f1_base = file1.rsplit('.', 1)[0]
43
+ f2_base = file2.rsplit('.', 1)[0]
44
+
45
+ if f1_base == f2_base:
46
+ # Same base name (e.g., Button.tsx and Button.module.css)
47
+ return True
48
+
49
+ return None
@@ -0,0 +1,6 @@
1
+ """Preview and UI package."""
2
+
3
+ from .renderer import PreviewRenderer
4
+ from .editor import InteractiveEditor
5
+
6
+ __all__ = ["PreviewRenderer", "InteractiveEditor"]
@@ -0,0 +1,224 @@
1
+ """Interactive editor for adjusting commit clusters."""
2
+
3
+ import uuid
4
+ from typing import List, Optional
5
+
6
+ from ..models import CommitCluster, FileDiff
7
+ from .renderer import PreviewRenderer
8
+
9
+ try:
10
+ from rich.prompt import Prompt, Confirm
11
+ from rich.console import Console
12
+ except ImportError:
13
+ Prompt = None
14
+ Confirm = None
15
+ Console = None
16
+
17
+ class InteractiveEditor:
18
+ """Provides a command loop to interactively edit commit clusters."""
19
+
20
+ def __init__(self, clusters: List[CommitCluster], total_files: int, renderer: PreviewRenderer):
21
+ self.clusters = clusters
22
+ self.total_files = total_files
23
+ self.renderer = renderer
24
+ self.console = Console() if Console else None
25
+
26
+ def edit(self) -> str:
27
+ """Run the interactive loop.
28
+
29
+ Returns:
30
+ 'y' to proceed, 'n' to cancel, 'd' for dry-run.
31
+ """
32
+ while True:
33
+ self.renderer.render_clusters(self.clusters, self.total_files)
34
+
35
+ if Prompt:
36
+ action = Prompt.ask(
37
+ "Action",
38
+ choices=["y", "n", "e", "s", "m", "v", "d", "q"],
39
+ default="y",
40
+ show_choices=False
41
+ )
42
+ # Display choices separately for clarity if needed, but standard prompt is fine
43
+ # Prompt loop: y=accept, n=cancel, e=edit, s=split, m=merge, v=move, d=delete, q=quit
44
+ else:
45
+ action = input("\nAction [y] Accept [e] Edit msg [s] Split [m] Merge [v] Move file [d] Delete file [q] Quit: ").lower().strip()
46
+
47
+ if action in {"y", "n", "q"}:
48
+ return action if action in {"y", "n"} else "n"
49
+
50
+ if action == "e":
51
+ self._action_edit_messages()
52
+ elif action == "s":
53
+ self._action_split()
54
+ elif action == "m":
55
+ self._action_merge()
56
+ elif action == "v":
57
+ self._action_move()
58
+ elif action == "d":
59
+ self._action_delete()
60
+
61
+ def _action_edit_messages(self):
62
+ """Edit commit messages inline."""
63
+ for index, cluster in enumerate(self.clusters, 1):
64
+ current = cluster.message or cluster.summary or "chore: update files"
65
+ new_message = input(f"Commit {index} message [{current}]: ").strip()
66
+ if new_message:
67
+ cluster.message = new_message
68
+
69
+ def _get_cluster_index(self, prompt_text: str) -> Optional[int]:
70
+ """Helper to get a valid cluster index."""
71
+ try:
72
+ val = input(prompt_text).strip()
73
+ if not val:
74
+ return None
75
+ idx = int(val) - 1
76
+ if 0 <= idx < len(self.clusters):
77
+ return idx
78
+ print("Invalid cluster number.")
79
+ return None
80
+ except ValueError:
81
+ print("Please enter a valid number.")
82
+ return None
83
+
84
+ def _action_split(self):
85
+ """Split a cluster by moving selected files into a new cluster."""
86
+ idx = self._get_cluster_index("Cluster number to split: ")
87
+ if idx is None:
88
+ return
89
+
90
+ cluster = self.clusters[idx]
91
+ if len(cluster.files) <= 1:
92
+ print("Cannot split a cluster with 1 or 0 files.")
93
+ return
94
+
95
+ print("Files in cluster:")
96
+ for i, f in enumerate(cluster.files, 1):
97
+ print(f" {i}. {f.file_path}")
98
+
99
+ choices = input("Enter file numbers to move to new cluster (comma separated): ").strip()
100
+ if not choices:
101
+ return
102
+
103
+ try:
104
+ file_indices = [int(c.strip()) - 1 for c in choices.split(",")]
105
+ except ValueError:
106
+ print("Invalid input.")
107
+ return
108
+
109
+ files_to_move = []
110
+ keep_files = []
111
+ for i, f in enumerate(cluster.files):
112
+ if i in file_indices:
113
+ files_to_move.append(f)
114
+ else:
115
+ keep_files.append(f)
116
+
117
+ if not files_to_move or not keep_files:
118
+ print("Must leave at least one file in the original cluster.")
119
+ return
120
+
121
+ cluster.files = keep_files
122
+
123
+ new_cluster = CommitCluster(
124
+ id=str(uuid.uuid4()),
125
+ files=files_to_move,
126
+ reason="Manually split by user",
127
+ confidence=1.0
128
+ )
129
+ self.clusters.append(new_cluster)
130
+ print("Cluster split.")
131
+
132
+ def _action_merge(self):
133
+ """Merge two clusters."""
134
+ if len(self.clusters) < 2:
135
+ print("Not enough clusters to merge.")
136
+ return
137
+
138
+ idx1 = self._get_cluster_index("First cluster number to merge: ")
139
+ if idx1 is None: return
140
+ idx2 = self._get_cluster_index("Second cluster number to merge: ")
141
+ if idx2 is None: return
142
+
143
+ if idx1 == idx2:
144
+ print("Cannot merge a cluster with itself.")
145
+ return
146
+
147
+ c1 = self.clusters[idx1]
148
+ c2 = self.clusters[idx2]
149
+
150
+ c1.files.extend(c2.files)
151
+ c1.reason = "Manually merged by user"
152
+ c1.confidence = 1.0
153
+
154
+ self.clusters.pop(idx2)
155
+ print("Clusters merged.")
156
+
157
+ def _action_move(self):
158
+ """Move a file from one cluster to another."""
159
+ if len(self.clusters) < 2:
160
+ print("Not enough clusters to move files between.")
161
+ return
162
+
163
+ src_idx = self._get_cluster_index("Source cluster number: ")
164
+ if src_idx is None: return
165
+
166
+ cluster = self.clusters[src_idx]
167
+ if not cluster.files:
168
+ return
169
+
170
+ print("Files in source cluster:")
171
+ for i, f in enumerate(cluster.files, 1):
172
+ print(f" {i}. {f.file_path}")
173
+
174
+ try:
175
+ f_idx = int(input("File number to move: ").strip()) - 1
176
+ if not (0 <= f_idx < len(cluster.files)):
177
+ print("Invalid file number.")
178
+ return
179
+ except ValueError:
180
+ print("Invalid input.")
181
+ return
182
+
183
+ tgt_idx = self._get_cluster_index("Target cluster number: ")
184
+ if tgt_idx is None or tgt_idx == src_idx:
185
+ print("Invalid target.")
186
+ return
187
+
188
+ file_to_move = cluster.files.pop(f_idx)
189
+ self.clusters[tgt_idx].files.append(file_to_move)
190
+
191
+ # Cleanup empty clusters
192
+ if not cluster.files:
193
+ self.clusters.pop(src_idx)
194
+
195
+ print("File moved.")
196
+
197
+ def _action_delete(self):
198
+ """Remove a file from the commit process."""
199
+ src_idx = self._get_cluster_index("Cluster number containing file: ")
200
+ if src_idx is None: return
201
+
202
+ cluster = self.clusters[src_idx]
203
+ if not cluster.files: return
204
+
205
+ print("Files in cluster:")
206
+ for i, f in enumerate(cluster.files, 1):
207
+ print(f" {i}. {f.file_path}")
208
+
209
+ try:
210
+ f_idx = int(input("File number to delete: ").strip()) - 1
211
+ if not (0 <= f_idx < len(cluster.files)):
212
+ print("Invalid file number.")
213
+ return
214
+ except ValueError:
215
+ print("Invalid input.")
216
+ return
217
+
218
+ cluster.files.pop(f_idx)
219
+ self.total_files -= 1
220
+
221
+ if not cluster.files:
222
+ self.clusters.pop(src_idx)
223
+
224
+ print("File removed from commits.")