codegraph-cli 2.1.1__py3-none-any.whl → 2.1.2__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.
- codegraph_cli/__init__.py +1 -1
- codegraph_cli/agents.py +59 -3
- codegraph_cli/chat_agent.py +58 -11
- codegraph_cli/cli.py +569 -54
- codegraph_cli/cli_chat.py +200 -95
- codegraph_cli/cli_diagnose.py +13 -2
- codegraph_cli/cli_docs.py +207 -0
- codegraph_cli/cli_explore.py +1053 -0
- codegraph_cli/cli_export.py +941 -0
- codegraph_cli/cli_groups.py +33 -0
- codegraph_cli/cli_health.py +316 -0
- codegraph_cli/cli_history.py +213 -0
- codegraph_cli/cli_onboard.py +380 -0
- codegraph_cli/cli_quickstart.py +256 -0
- codegraph_cli/cli_refactor.py +17 -3
- codegraph_cli/cli_setup.py +12 -12
- codegraph_cli/cli_suggestions.py +90 -0
- codegraph_cli/cli_test.py +17 -3
- codegraph_cli/cli_tui.py +210 -0
- codegraph_cli/cli_v2.py +24 -4
- codegraph_cli/cli_watch.py +158 -0
- codegraph_cli/cli_workflows.py +255 -0
- codegraph_cli/codegen_agent.py +15 -1
- codegraph_cli/config.py +18 -5
- codegraph_cli/context_manager.py +117 -15
- codegraph_cli/crew_agents.py +26 -7
- codegraph_cli/crew_chat.py +141 -12
- codegraph_cli/crew_tools.py +21 -1
- codegraph_cli/embeddings.py +95 -5
- codegraph_cli/llm.py +42 -55
- codegraph_cli/project_context.py +64 -1
- codegraph_cli/rag.py +282 -19
- codegraph_cli/storage.py +310 -14
- codegraph_cli/vector_store.py +110 -8
- {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +35 -24
- codegraph_cli-2.1.2.dist-info/RECORD +55 -0
- codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
- codegraph_cli-2.1.1.dist-info/RECORD +0 -43
- codegraph_cli-2.1.1.dist-info/entry_points.txt +0 -2
- {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
- {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
- {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Command hierarchy groups for organized CLI experience.
|
|
2
|
+
|
|
3
|
+
Provides logical grouping of commands under:
|
|
4
|
+
cg config — Configuration management
|
|
5
|
+
cg project — Project management
|
|
6
|
+
cg analyze — Code analysis
|
|
7
|
+
cg chat — Interactive AI chat
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
# ── Configuration group ──────────────────────────────────────
|
|
15
|
+
config_grp = typer.Typer(
|
|
16
|
+
help="⚙️ Configuration — LLM, embedding, and setup.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
rich_markup_mode="rich",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# ── Project management group ─────────────────────────────────
|
|
22
|
+
project_grp = typer.Typer(
|
|
23
|
+
help="📂 Projects — index, load, and manage project memories.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
rich_markup_mode="rich",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ── Analysis group ───────────────────────────────────────────
|
|
29
|
+
analyze_grp = typer.Typer(
|
|
30
|
+
help="🔍 Analysis — search, impact, graph, and RAG context.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
rich_markup_mode="rich",
|
|
33
|
+
)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Project health dashboard for CodeGraph CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import BarColumn, Progress, TextColumn
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .storage import GraphStore, ProjectManager
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
health_app = typer.Typer(help="🏥 Project health dashboard")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _render_bar(percentage: float) -> str:
|
|
23
|
+
"""Render a simple text progress bar."""
|
|
24
|
+
filled = int(percentage / 10)
|
|
25
|
+
empty = 10 - filled
|
|
26
|
+
bar = "█" * filled + "░" * empty
|
|
27
|
+
if percentage >= 80:
|
|
28
|
+
color = "green"
|
|
29
|
+
elif percentage >= 60:
|
|
30
|
+
color = "yellow"
|
|
31
|
+
else:
|
|
32
|
+
color = "red"
|
|
33
|
+
return f"[{color}]{bar}[/{color}] {percentage:.0f}%"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _score_color(score: float) -> str:
|
|
37
|
+
if score >= 80:
|
|
38
|
+
return "green"
|
|
39
|
+
elif score >= 60:
|
|
40
|
+
return "yellow"
|
|
41
|
+
return "red"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _analyze_code_quality(store: GraphStore) -> Dict[str, Any]:
|
|
45
|
+
"""Analyze code quality from indexed nodes."""
|
|
46
|
+
nodes = store.get_nodes()
|
|
47
|
+
if not nodes:
|
|
48
|
+
return {"score": 0, "total_nodes": 0, "functions": 0, "classes": 0}
|
|
49
|
+
|
|
50
|
+
functions = [n for n in nodes if n.get("node_type") == "function"]
|
|
51
|
+
classes = [n for n in nodes if n.get("node_type") == "class"]
|
|
52
|
+
|
|
53
|
+
# Heuristic quality score based on:
|
|
54
|
+
# - docstring presence
|
|
55
|
+
# - reasonable function size
|
|
56
|
+
# - naming conventions
|
|
57
|
+
score_points = 0
|
|
58
|
+
total_checks = 0
|
|
59
|
+
|
|
60
|
+
for fn in functions:
|
|
61
|
+
total_checks += 1
|
|
62
|
+
snippet = fn.get("snippet", "")
|
|
63
|
+
# Has docstring?
|
|
64
|
+
if '"""' in snippet or "'''" in snippet:
|
|
65
|
+
score_points += 1
|
|
66
|
+
else:
|
|
67
|
+
score_points += 0.3
|
|
68
|
+
# Reasonable size (< 50 lines)
|
|
69
|
+
start = fn.get("start_line", 0)
|
|
70
|
+
end = fn.get("end_line", 0)
|
|
71
|
+
if 0 < (end - start) < 50:
|
|
72
|
+
score_points += 0.5
|
|
73
|
+
total_checks += 0.5
|
|
74
|
+
else:
|
|
75
|
+
total_checks += 0.5
|
|
76
|
+
|
|
77
|
+
quality_pct = (score_points / max(total_checks, 1)) * 100
|
|
78
|
+
return {
|
|
79
|
+
"score": min(quality_pct, 100),
|
|
80
|
+
"total_nodes": len(nodes),
|
|
81
|
+
"functions": len(functions),
|
|
82
|
+
"classes": len(classes),
|
|
83
|
+
"documented": sum(
|
|
84
|
+
1 for fn in functions if '"""' in fn.get("snippet", "") or "'''" in fn.get("snippet", "")
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _analyze_complexity(store: GraphStore) -> Dict[str, Any]:
|
|
90
|
+
"""Estimate code complexity from indexed functions."""
|
|
91
|
+
nodes = store.get_nodes()
|
|
92
|
+
functions = [n for n in nodes if n.get("node_type") == "function"]
|
|
93
|
+
|
|
94
|
+
if not functions:
|
|
95
|
+
return {"avg": 0, "max": 0, "high_complexity": []}
|
|
96
|
+
|
|
97
|
+
complexities = []
|
|
98
|
+
high_complexity = []
|
|
99
|
+
|
|
100
|
+
for fn in functions:
|
|
101
|
+
snippet = fn.get("snippet", "")
|
|
102
|
+
# Rough cyclomatic complexity estimate
|
|
103
|
+
complexity = 1 # base
|
|
104
|
+
for keyword in ["if ", "elif ", "else:", "for ", "while ", "except ", "and ", "or ", "case "]:
|
|
105
|
+
complexity += snippet.count(keyword)
|
|
106
|
+
|
|
107
|
+
complexities.append(complexity)
|
|
108
|
+
if complexity > 10:
|
|
109
|
+
high_complexity.append({
|
|
110
|
+
"name": fn.get("qualname", fn.get("name", "unknown")),
|
|
111
|
+
"complexity": complexity,
|
|
112
|
+
"file": fn.get("file_path", ""),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
avg_complexity = sum(complexities) / len(complexities) if complexities else 0
|
|
116
|
+
max_complexity = max(complexities) if complexities else 0
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"avg": avg_complexity,
|
|
120
|
+
"max": max_complexity,
|
|
121
|
+
"high_complexity": sorted(high_complexity, key=lambda x: x["complexity"], reverse=True)[:10],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _analyze_security(store: GraphStore) -> Dict[str, Any]:
|
|
126
|
+
"""Quick security scan from indexed code."""
|
|
127
|
+
nodes = store.get_nodes()
|
|
128
|
+
critical = 0
|
|
129
|
+
high = 0
|
|
130
|
+
medium = 0
|
|
131
|
+
|
|
132
|
+
security_patterns = {
|
|
133
|
+
"critical": ["eval(", "exec(", "os.system(", "subprocess.call(shell=True"],
|
|
134
|
+
"high": ["pickle.loads", "yaml.load(", "password", "secret", "SQL"],
|
|
135
|
+
"medium": ["print(", "TODO", "FIXME", "HACK"],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for node in nodes:
|
|
139
|
+
snippet = node.get("snippet", "")
|
|
140
|
+
for pattern in security_patterns["critical"]:
|
|
141
|
+
if pattern in snippet:
|
|
142
|
+
critical += 1
|
|
143
|
+
for pattern in security_patterns["high"]:
|
|
144
|
+
if pattern.lower() in snippet.lower():
|
|
145
|
+
high += 1
|
|
146
|
+
for pattern in security_patterns["medium"]:
|
|
147
|
+
if pattern in snippet:
|
|
148
|
+
medium += 1
|
|
149
|
+
|
|
150
|
+
return {"critical": critical, "high": high, "medium": medium}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _generate_recommendations(
|
|
154
|
+
quality: Dict[str, Any],
|
|
155
|
+
complexity: Dict[str, Any],
|
|
156
|
+
security: Dict[str, Any],
|
|
157
|
+
) -> List[str]:
|
|
158
|
+
"""Generate actionable recommendations."""
|
|
159
|
+
recs = []
|
|
160
|
+
|
|
161
|
+
documented = quality.get("documented", 0)
|
|
162
|
+
total_fns = quality.get("functions", 0)
|
|
163
|
+
if total_fns > 0 and documented / total_fns < 0.5:
|
|
164
|
+
recs.append(
|
|
165
|
+
f"Add docstrings: only {documented}/{total_fns} functions are documented. "
|
|
166
|
+
"Run 'cg v2 review <file>' for suggestions."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if security["critical"] > 0:
|
|
170
|
+
recs.append(
|
|
171
|
+
f"Fix {security['critical']} critical security pattern(s) (eval/exec/os.system). "
|
|
172
|
+
"Run 'cg v2 review <file> --check security'."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if security["high"] > 0:
|
|
176
|
+
recs.append(
|
|
177
|
+
f"Review {security['high']} high-severity pattern(s). "
|
|
178
|
+
"Run 'cg v2 review <file> --check security'."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if complexity["max"] > 15:
|
|
182
|
+
recs.append(
|
|
183
|
+
f"Refactor high-complexity functions (max: {complexity['max']}). "
|
|
184
|
+
"Run 'cg v2 refactor extract-function' to simplify."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if quality["score"] < 70:
|
|
188
|
+
recs.append("Run 'cg v2 review <file>' on low-quality modules to get improvement suggestions.")
|
|
189
|
+
|
|
190
|
+
if not recs:
|
|
191
|
+
recs.append("Your project looks healthy! Keep up the good work. 🎉")
|
|
192
|
+
|
|
193
|
+
return recs
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@health_app.command("dashboard")
|
|
197
|
+
def health_dashboard():
|
|
198
|
+
"""🏥 Project health dashboard.
|
|
199
|
+
|
|
200
|
+
Shows code quality, complexity, security patterns, and recommendations.
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
cg health dashboard
|
|
204
|
+
"""
|
|
205
|
+
pm = ProjectManager()
|
|
206
|
+
project = pm.get_current_project()
|
|
207
|
+
if not project:
|
|
208
|
+
console.print("[red]✗[/red] No project loaded. Run 'cg index <path>' first.")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
project_dir = pm.project_dir(project)
|
|
212
|
+
store = GraphStore(project_dir)
|
|
213
|
+
|
|
214
|
+
console.print(f"\n[bold cyan]🏥 Analyzing project health for '{project}'...[/bold cyan]\n")
|
|
215
|
+
|
|
216
|
+
with Progress(
|
|
217
|
+
TextColumn("[progress.description]{task.description}"),
|
|
218
|
+
BarColumn(),
|
|
219
|
+
console=console,
|
|
220
|
+
) as progress:
|
|
221
|
+
task = progress.add_task("[cyan]Running analyzers...", total=3)
|
|
222
|
+
|
|
223
|
+
progress.update(task, description="[cyan]Analyzing code quality...")
|
|
224
|
+
quality = _analyze_code_quality(store)
|
|
225
|
+
progress.advance(task)
|
|
226
|
+
|
|
227
|
+
progress.update(task, description="[cyan]Analyzing complexity...")
|
|
228
|
+
complexity = _analyze_complexity(store)
|
|
229
|
+
progress.advance(task)
|
|
230
|
+
|
|
231
|
+
progress.update(task, description="[cyan]Scanning for security patterns...")
|
|
232
|
+
security = _analyze_security(store)
|
|
233
|
+
progress.advance(task)
|
|
234
|
+
|
|
235
|
+
# ── Overall score ────────────────────────────────────────
|
|
236
|
+
quality_score = quality["score"]
|
|
237
|
+
complexity_penalty = min(complexity["avg"] * 2, 30)
|
|
238
|
+
security_penalty = security["critical"] * 10 + security["high"] * 3
|
|
239
|
+
overall = max(0, min(100, quality_score - complexity_penalty - security_penalty))
|
|
240
|
+
color = _score_color(overall)
|
|
241
|
+
|
|
242
|
+
console.print(
|
|
243
|
+
Panel.fit(
|
|
244
|
+
f"[bold {color}]{overall:.0f}%[/bold {color}]",
|
|
245
|
+
title="[bold]Overall Health Score[/bold]",
|
|
246
|
+
border_style=color,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# ── Detailed metrics ─────────────────────────────────────
|
|
251
|
+
table = Table(title="\nDetailed Metrics", show_header=True, show_lines=False)
|
|
252
|
+
table.add_column("Metric", style="cyan", width=18)
|
|
253
|
+
table.add_column("Score", width=24)
|
|
254
|
+
table.add_column("Details", min_width=30)
|
|
255
|
+
|
|
256
|
+
table.add_row(
|
|
257
|
+
"Code Quality",
|
|
258
|
+
_render_bar(quality_score),
|
|
259
|
+
f"{quality['functions']} functions, {quality['classes']} classes, "
|
|
260
|
+
f"{quality['documented']} documented",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
complexity_pct = max(0, 100 - complexity["avg"] * 5)
|
|
264
|
+
table.add_row(
|
|
265
|
+
"Complexity",
|
|
266
|
+
_render_bar(complexity_pct),
|
|
267
|
+
f"Avg: {complexity['avg']:.1f}, Max: {complexity['max']}",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
sec_pct = max(0, 100 - security["critical"] * 20 - security["high"] * 5 - security["medium"])
|
|
271
|
+
sec_status = (
|
|
272
|
+
"🔴 Critical" if security["critical"] > 0
|
|
273
|
+
else "🟡 Warning" if security["high"] > 0
|
|
274
|
+
else "🟢 Good"
|
|
275
|
+
)
|
|
276
|
+
table.add_row(
|
|
277
|
+
"Security",
|
|
278
|
+
sec_status,
|
|
279
|
+
f"{security['critical']} critical, {security['high']} high, {security['medium']} medium",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
table.add_row(
|
|
283
|
+
"Total Nodes",
|
|
284
|
+
str(quality["total_nodes"]),
|
|
285
|
+
f"Indexed symbols in project '{project}'",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
console.print(table)
|
|
289
|
+
|
|
290
|
+
# ── High complexity functions ────────────────────────────
|
|
291
|
+
if complexity["high_complexity"]:
|
|
292
|
+
console.print("\n[bold yellow]⚠️ High-Complexity Functions[/bold yellow]")
|
|
293
|
+
for item in complexity["high_complexity"][:5]:
|
|
294
|
+
console.print(f" • {item['name']} (complexity: {item['complexity']}) — {item['file']}")
|
|
295
|
+
|
|
296
|
+
# ── Recommendations ──────────────────────────────────────
|
|
297
|
+
recs = _generate_recommendations(quality, complexity, security)
|
|
298
|
+
console.print(
|
|
299
|
+
"\n"
|
|
300
|
+
+ Panel(
|
|
301
|
+
"\n".join([f" • {rec}" for rec in recs]),
|
|
302
|
+
title="[bold yellow]📋 Recommendations[/bold yellow]",
|
|
303
|
+
border_style="yellow",
|
|
304
|
+
).markup # type: ignore
|
|
305
|
+
if False
|
|
306
|
+
else ""
|
|
307
|
+
)
|
|
308
|
+
console.print(
|
|
309
|
+
Panel(
|
|
310
|
+
"\n".join([f" • {rec}" for rec in recs]),
|
|
311
|
+
title="[bold yellow]📋 Recommendations[/bold yellow]",
|
|
312
|
+
border_style="yellow",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
store.close()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Undo/redo system and change history for CodeGraph CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from .config import BASE_DIR
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
history_app = typer.Typer(help="📜 Change history and undo/redo")
|
|
19
|
+
|
|
20
|
+
HISTORY_FILE = BASE_DIR / "change_history.json"
|
|
21
|
+
MAX_HISTORY = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangeHistory:
|
|
25
|
+
"""Track code changes for undo/redo support."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.history: List[dict] = self._load()
|
|
29
|
+
self.current_index: int = len(self.history) - 1
|
|
30
|
+
|
|
31
|
+
def _load(self) -> List[dict]:
|
|
32
|
+
if HISTORY_FILE.exists():
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(HISTORY_FILE.read_text())
|
|
35
|
+
except (json.JSONDecodeError, OSError):
|
|
36
|
+
return []
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
def _save(self) -> None:
|
|
40
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
# Trim to max history
|
|
42
|
+
data = self.history[-MAX_HISTORY:]
|
|
43
|
+
HISTORY_FILE.write_text(json.dumps(data, indent=2))
|
|
44
|
+
|
|
45
|
+
def record_change(
|
|
46
|
+
self,
|
|
47
|
+
change_type: str,
|
|
48
|
+
description: str,
|
|
49
|
+
files_changed: List[str],
|
|
50
|
+
backup_id: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Record a change for undo/redo."""
|
|
53
|
+
# Truncate any "future" entries if we've undone things
|
|
54
|
+
self.history = self.history[: self.current_index + 1]
|
|
55
|
+
self.history.append(
|
|
56
|
+
{
|
|
57
|
+
"timestamp": datetime.now().isoformat(),
|
|
58
|
+
"type": change_type,
|
|
59
|
+
"description": description,
|
|
60
|
+
"files": files_changed,
|
|
61
|
+
"backup_id": backup_id,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
self.current_index = len(self.history) - 1
|
|
65
|
+
self._save()
|
|
66
|
+
|
|
67
|
+
def can_undo(self) -> bool:
|
|
68
|
+
return self.current_index >= 0 and len(self.history) > 0
|
|
69
|
+
|
|
70
|
+
def can_redo(self) -> bool:
|
|
71
|
+
return self.current_index < len(self.history) - 1
|
|
72
|
+
|
|
73
|
+
def get_undo_entry(self) -> Optional[dict]:
|
|
74
|
+
if not self.can_undo():
|
|
75
|
+
return None
|
|
76
|
+
entry = self.history[self.current_index]
|
|
77
|
+
self.current_index -= 1
|
|
78
|
+
self._save()
|
|
79
|
+
return entry
|
|
80
|
+
|
|
81
|
+
def get_redo_entry(self) -> Optional[dict]:
|
|
82
|
+
if not self.can_redo():
|
|
83
|
+
return None
|
|
84
|
+
self.current_index += 1
|
|
85
|
+
entry = self.history[self.current_index]
|
|
86
|
+
self._save()
|
|
87
|
+
return entry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@history_app.command("undo")
|
|
91
|
+
def undo(
|
|
92
|
+
steps: int = typer.Option(1, "--steps", "-n", min=1, help="Number of steps to undo."),
|
|
93
|
+
):
|
|
94
|
+
"""⏪ Undo last code changes.
|
|
95
|
+
|
|
96
|
+
Restores files from backups created during generate, refactor, or fix operations.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
cg undo
|
|
100
|
+
cg undo --steps 3
|
|
101
|
+
"""
|
|
102
|
+
from .diff_engine import DiffEngine
|
|
103
|
+
|
|
104
|
+
hist = ChangeHistory()
|
|
105
|
+
|
|
106
|
+
if not hist.can_undo():
|
|
107
|
+
console.print("[yellow]No changes to undo.[/yellow]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
diff_engine = DiffEngine()
|
|
111
|
+
undone = 0
|
|
112
|
+
|
|
113
|
+
for _ in range(steps):
|
|
114
|
+
entry = hist.get_undo_entry()
|
|
115
|
+
if not entry:
|
|
116
|
+
console.print("[yellow]No more changes to undo.[/yellow]")
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
console.print(f" [yellow]⏪ Undoing:[/yellow] {entry['description']}")
|
|
120
|
+
|
|
121
|
+
success = diff_engine.rollback(entry["backup_id"])
|
|
122
|
+
if success:
|
|
123
|
+
console.print(f" [green]✓[/green] Restored {len(entry['files'])} file(s)")
|
|
124
|
+
undone += 1
|
|
125
|
+
else:
|
|
126
|
+
console.print(f" [red]✗[/red] Backup not found: {entry['backup_id']}")
|
|
127
|
+
|
|
128
|
+
if undone:
|
|
129
|
+
console.print(f"\n[green]Undid {undone} change(s).[/green]")
|
|
130
|
+
console.print("[dim]Use 'cg redo' to reapply.[/dim]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@history_app.command("redo")
|
|
134
|
+
def redo(
|
|
135
|
+
steps: int = typer.Option(1, "--steps", "-n", min=1, help="Number of steps to redo."),
|
|
136
|
+
):
|
|
137
|
+
"""⏩ Redo previously undone changes.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
cg redo
|
|
141
|
+
cg redo --steps 2
|
|
142
|
+
"""
|
|
143
|
+
hist = ChangeHistory()
|
|
144
|
+
|
|
145
|
+
if not hist.can_redo():
|
|
146
|
+
console.print("[yellow]No changes to redo.[/yellow]")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
console.print("[cyan]Redo is not yet fully supported — please re-run the command.[/cyan]")
|
|
150
|
+
console.print("[dim]History tracking is active; full redo support coming soon.[/dim]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@history_app.command("show")
|
|
154
|
+
def show_history(
|
|
155
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, help="Number of entries to show."),
|
|
156
|
+
):
|
|
157
|
+
"""📜 Show change history.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
cg history show
|
|
161
|
+
cg history show --limit 20
|
|
162
|
+
"""
|
|
163
|
+
hist = ChangeHistory()
|
|
164
|
+
|
|
165
|
+
if not hist.history:
|
|
166
|
+
console.print("[yellow]No change history yet.[/yellow]")
|
|
167
|
+
console.print("[dim]History is recorded when you generate, refactor, or fix code.[/dim]")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
table = Table(title="Change History", show_lines=False)
|
|
171
|
+
table.add_column("#", style="dim", width=4)
|
|
172
|
+
table.add_column("Time", style="cyan", width=16)
|
|
173
|
+
table.add_column("Type", style="magenta", width=12)
|
|
174
|
+
table.add_column("Description", min_width=30)
|
|
175
|
+
table.add_column("Files", justify="right", style="green", width=6)
|
|
176
|
+
|
|
177
|
+
entries = hist.history[-limit:]
|
|
178
|
+
for i, change in enumerate(reversed(entries), 1):
|
|
179
|
+
try:
|
|
180
|
+
timestamp = datetime.fromisoformat(change["timestamp"])
|
|
181
|
+
time_str = timestamp.strftime("%Y-%m-%d %H:%M")
|
|
182
|
+
except (ValueError, KeyError):
|
|
183
|
+
time_str = "unknown"
|
|
184
|
+
|
|
185
|
+
marker = " ◄" if (len(hist.history) - i) == hist.current_index else ""
|
|
186
|
+
table.add_row(
|
|
187
|
+
str(i),
|
|
188
|
+
time_str,
|
|
189
|
+
change.get("type", "unknown"),
|
|
190
|
+
change.get("description", "—") + marker,
|
|
191
|
+
str(len(change.get("files", []))),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
console.print(table)
|
|
195
|
+
console.print(f"\n[dim]Showing {len(entries)} of {len(hist.history)} entries[/dim]")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@history_app.command("clear")
|
|
199
|
+
def clear_history():
|
|
200
|
+
"""🗑️ Clear all change history.
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
cg history clear
|
|
204
|
+
"""
|
|
205
|
+
if not HISTORY_FILE.exists():
|
|
206
|
+
console.print("[yellow]No history to clear.[/yellow]")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
if typer.confirm("Clear all change history?", default=False):
|
|
210
|
+
HISTORY_FILE.unlink(missing_ok=True)
|
|
211
|
+
console.print("[green]✓ History cleared.[/green]")
|
|
212
|
+
else:
|
|
213
|
+
console.print("[dim]Cancelled.[/dim]")
|