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
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,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,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.")
|