tunacode-cli 0.0.4__py3-none-any.whl → 0.0.6__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands.py +91 -33
- tunacode/cli/model_selector.py +178 -0
- tunacode/cli/repl.py +11 -10
- tunacode/configuration/models.py +11 -1
- tunacode/constants.py +11 -11
- tunacode/context.py +1 -3
- tunacode/core/agents/main.py +52 -94
- tunacode/core/agents/tinyagent_main.py +171 -0
- tunacode/core/setup/git_safety_setup.py +39 -51
- tunacode/core/setup/optimized_coordinator.py +73 -0
- tunacode/exceptions.py +13 -15
- tunacode/services/enhanced_undo_service.py +322 -0
- tunacode/services/project_undo_service.py +311 -0
- tunacode/services/undo_service.py +18 -21
- tunacode/tools/base.py +11 -20
- tunacode/tools/tinyagent_tools.py +103 -0
- tunacode/tools/update_file.py +24 -14
- tunacode/tools/write_file.py +9 -7
- tunacode/types.py +2 -2
- tunacode/ui/completers.py +98 -33
- tunacode/ui/input.py +8 -7
- tunacode/ui/keybindings.py +1 -3
- tunacode/ui/lexers.py +16 -17
- tunacode/ui/output.py +9 -3
- tunacode/ui/panels.py +4 -4
- tunacode/ui/prompt_manager.py +6 -4
- tunacode/utils/lazy_imports.py +59 -0
- tunacode/utils/regex_cache.py +33 -0
- tunacode/utils/system.py +13 -13
- tunacode_cli-0.0.6.dist-info/METADATA +235 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/RECORD +35 -27
- tunacode_cli-0.0.4.dist-info/METADATA +0 -247
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced undo service with multiple failsafe mechanisms.
|
|
3
|
+
Provides three layers of protection for file operations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from tunacode.core.state import StateManager
|
|
14
|
+
from tunacode.utils.system import get_session_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnhancedUndoService:
|
|
18
|
+
"""Multi-layer undo system with failsafes."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, state_manager: StateManager):
|
|
21
|
+
self.state_manager = state_manager
|
|
22
|
+
self.session_dir = get_session_dir(state_manager)
|
|
23
|
+
|
|
24
|
+
# Layer 1: File backups directory
|
|
25
|
+
self.backup_dir = self.session_dir / "backups"
|
|
26
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
# Layer 2: Operation log
|
|
29
|
+
self.op_log_file = self.session_dir / "operations.jsonl"
|
|
30
|
+
|
|
31
|
+
# Layer 3: Git (existing system)
|
|
32
|
+
self.git_dir = self.session_dir / ".git"
|
|
33
|
+
|
|
34
|
+
self._init_systems()
|
|
35
|
+
|
|
36
|
+
def _init_systems(self):
|
|
37
|
+
"""Initialize all undo systems."""
|
|
38
|
+
# Ensure operation log exists
|
|
39
|
+
if not self.op_log_file.exists():
|
|
40
|
+
self.op_log_file.touch()
|
|
41
|
+
|
|
42
|
+
# ===== LAYER 1: File Backups =====
|
|
43
|
+
|
|
44
|
+
def backup_file(self, filepath: Path) -> Optional[Path]:
|
|
45
|
+
"""
|
|
46
|
+
Create a timestamped backup of a file before modification.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Path to backup file, or None if backup failed
|
|
50
|
+
"""
|
|
51
|
+
# Note: We'll create empty backup even if file doesn't exist yet
|
|
52
|
+
# This helps track file creation operations
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Create unique backup name with timestamp
|
|
56
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
57
|
+
backup_name = f"{filepath.name}.{timestamp}.bak"
|
|
58
|
+
backup_path = self.backup_dir / backup_name
|
|
59
|
+
|
|
60
|
+
# Copy the file
|
|
61
|
+
shutil.copy2(filepath, backup_path)
|
|
62
|
+
|
|
63
|
+
# Log the backup
|
|
64
|
+
self._log_operation(
|
|
65
|
+
{
|
|
66
|
+
"type": "backup",
|
|
67
|
+
"original": str(filepath),
|
|
68
|
+
"backup": str(backup_path),
|
|
69
|
+
"timestamp": timestamp,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return backup_path
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"⚠️ Failed to create backup: {e}")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def restore_from_backup(self, original_path: Path) -> Tuple[bool, str]:
|
|
79
|
+
"""
|
|
80
|
+
Restore a file from its most recent backup.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
(success, message) tuple
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
# Find most recent backup for this file
|
|
87
|
+
backups = sorted(
|
|
88
|
+
[f for f in self.backup_dir.glob(f"{original_path.name}.*.bak")], reverse=True
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not backups:
|
|
92
|
+
return False, f"No backups found for {original_path.name}"
|
|
93
|
+
|
|
94
|
+
latest_backup = backups[0]
|
|
95
|
+
|
|
96
|
+
# Restore the file
|
|
97
|
+
shutil.copy2(latest_backup, original_path)
|
|
98
|
+
|
|
99
|
+
# Log the restore
|
|
100
|
+
self._log_operation(
|
|
101
|
+
{
|
|
102
|
+
"type": "restore",
|
|
103
|
+
"file": str(original_path),
|
|
104
|
+
"from_backup": str(latest_backup),
|
|
105
|
+
"timestamp": datetime.now().isoformat(),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return True, f"Restored {original_path.name} from backup"
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return False, f"Failed to restore from backup: {e}"
|
|
113
|
+
|
|
114
|
+
# ===== LAYER 2: Operation Log =====
|
|
115
|
+
|
|
116
|
+
def _log_operation(self, operation: Dict):
|
|
117
|
+
"""Log an operation to the operations file."""
|
|
118
|
+
try:
|
|
119
|
+
with open(self.op_log_file, "a") as f:
|
|
120
|
+
json.dump(operation, f)
|
|
121
|
+
f.write("\n")
|
|
122
|
+
except Exception:
|
|
123
|
+
pass # Silent fail for logging
|
|
124
|
+
|
|
125
|
+
def log_file_operation(
|
|
126
|
+
self,
|
|
127
|
+
op_type: str,
|
|
128
|
+
filepath: Path,
|
|
129
|
+
old_content: Optional[str] = None,
|
|
130
|
+
new_content: Optional[str] = None,
|
|
131
|
+
):
|
|
132
|
+
"""
|
|
133
|
+
Log a file operation with content for potential recovery.
|
|
134
|
+
"""
|
|
135
|
+
operation = {
|
|
136
|
+
"type": "file_operation",
|
|
137
|
+
"operation": op_type,
|
|
138
|
+
"file": str(filepath),
|
|
139
|
+
"timestamp": datetime.now().isoformat(),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# For safety, only log content for smaller files
|
|
143
|
+
if old_content and len(old_content) < 100_000: # 100KB limit
|
|
144
|
+
operation["old_content"] = old_content
|
|
145
|
+
if new_content and len(new_content) < 100_000:
|
|
146
|
+
operation["new_content"] = new_content
|
|
147
|
+
|
|
148
|
+
self._log_operation(operation)
|
|
149
|
+
|
|
150
|
+
def get_recent_operations(self, limit: int = 10) -> List[Dict]:
|
|
151
|
+
"""Get recent operations from the log."""
|
|
152
|
+
operations = []
|
|
153
|
+
try:
|
|
154
|
+
with open(self.op_log_file, "r") as f:
|
|
155
|
+
for line in f:
|
|
156
|
+
if line.strip():
|
|
157
|
+
operations.append(json.loads(line))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return operations[-limit:]
|
|
162
|
+
|
|
163
|
+
def undo_from_log(self) -> Tuple[bool, str]:
|
|
164
|
+
"""
|
|
165
|
+
Attempt to undo the last operation using the operation log.
|
|
166
|
+
"""
|
|
167
|
+
operations = self.get_recent_operations(20)
|
|
168
|
+
|
|
169
|
+
# Find the last file operation
|
|
170
|
+
for op in reversed(operations):
|
|
171
|
+
if op.get("type") == "file_operation" and "old_content" in op:
|
|
172
|
+
filepath = Path(op["file"])
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
# Restore old content
|
|
176
|
+
with open(filepath, "w") as f:
|
|
177
|
+
f.write(op["old_content"])
|
|
178
|
+
|
|
179
|
+
self._log_operation(
|
|
180
|
+
{
|
|
181
|
+
"type": "undo_from_log",
|
|
182
|
+
"restored_file": str(filepath),
|
|
183
|
+
"timestamp": datetime.now().isoformat(),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return True, f"Restored {filepath.name} from operation log"
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return False, f"Failed to restore from log: {e}"
|
|
190
|
+
|
|
191
|
+
return False, "No recoverable operations found in log"
|
|
192
|
+
|
|
193
|
+
# ===== LAYER 3: Git Integration =====
|
|
194
|
+
|
|
195
|
+
def git_commit(self, message: str = "Auto-save") -> bool:
|
|
196
|
+
"""Create a git commit if available."""
|
|
197
|
+
if not self.git_dir.exists():
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
git_dir_arg = f"--git-dir={self.git_dir}"
|
|
202
|
+
subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, timeout=5)
|
|
203
|
+
|
|
204
|
+
subprocess.run(
|
|
205
|
+
["git", git_dir_arg, "commit", "-m", message], capture_output=True, timeout=5
|
|
206
|
+
)
|
|
207
|
+
return True
|
|
208
|
+
except Exception:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
# ===== Unified Undo Interface =====
|
|
212
|
+
|
|
213
|
+
def perform_undo(self) -> Tuple[bool, str]:
|
|
214
|
+
"""
|
|
215
|
+
Perform undo with automatic failover:
|
|
216
|
+
1. Try Git first (if available)
|
|
217
|
+
2. Fall back to operation log
|
|
218
|
+
3. Fall back to file backups
|
|
219
|
+
"""
|
|
220
|
+
# Layer 3: Try Git first
|
|
221
|
+
if self.git_dir.exists():
|
|
222
|
+
try:
|
|
223
|
+
from tunacode.services.undo_service import perform_undo
|
|
224
|
+
|
|
225
|
+
success, message = perform_undo(self.state_manager)
|
|
226
|
+
if success:
|
|
227
|
+
return success, f"[Git] {message}"
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
# Layer 2: Try operation log
|
|
232
|
+
success, message = self.undo_from_log()
|
|
233
|
+
if success:
|
|
234
|
+
return success, f"[OpLog] {message}"
|
|
235
|
+
|
|
236
|
+
# Layer 1: Show available backups
|
|
237
|
+
backups = list(self.backup_dir.glob("*.bak"))
|
|
238
|
+
if backups:
|
|
239
|
+
recent_backups = sorted(backups, reverse=True)[:5]
|
|
240
|
+
backup_list = "\n".join([f" - {b.name}" for b in recent_backups])
|
|
241
|
+
return False, f"Manual restore available from backups:\n{backup_list}"
|
|
242
|
+
|
|
243
|
+
return False, "No undo information available"
|
|
244
|
+
|
|
245
|
+
def cleanup_old_backups(self, keep_recent: int = 50):
|
|
246
|
+
"""Clean up old backup files, keeping the most recent ones."""
|
|
247
|
+
backups = sorted(self.backup_dir.glob("*.bak"))
|
|
248
|
+
|
|
249
|
+
if len(backups) > keep_recent:
|
|
250
|
+
for backup in backups[:-keep_recent]:
|
|
251
|
+
try:
|
|
252
|
+
backup.unlink()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ===== Safe File Operations Wrapper =====
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SafeFileOperations:
|
|
261
|
+
"""Wrapper for file operations with automatic backup."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, undo_service: EnhancedUndoService):
|
|
264
|
+
self.undo = undo_service
|
|
265
|
+
|
|
266
|
+
async def safe_write(self, filepath: Path, content: str) -> Tuple[bool, str]:
|
|
267
|
+
"""Write file with automatic backup."""
|
|
268
|
+
filepath = Path(filepath)
|
|
269
|
+
|
|
270
|
+
# Get old content if file exists
|
|
271
|
+
old_content = None
|
|
272
|
+
if filepath.exists():
|
|
273
|
+
try:
|
|
274
|
+
old_content = filepath.read_text()
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
# Always create backup before write (even for new files)
|
|
279
|
+
self.undo.backup_file(filepath)
|
|
280
|
+
|
|
281
|
+
# Write new content
|
|
282
|
+
try:
|
|
283
|
+
filepath.write_text(content)
|
|
284
|
+
|
|
285
|
+
# Log the operation
|
|
286
|
+
self.undo.log_file_operation("write", filepath, old_content, content)
|
|
287
|
+
|
|
288
|
+
# Commit to git if available
|
|
289
|
+
self.undo.git_commit(f"Modified {filepath.name}")
|
|
290
|
+
|
|
291
|
+
return True, "File written successfully"
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
return False, f"Failed to write file: {e}"
|
|
295
|
+
|
|
296
|
+
async def safe_delete(self, filepath: Path) -> Tuple[bool, str]:
|
|
297
|
+
"""Delete file with automatic backup."""
|
|
298
|
+
filepath = Path(filepath)
|
|
299
|
+
|
|
300
|
+
if not filepath.exists():
|
|
301
|
+
return False, "File does not exist"
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Create backup first
|
|
305
|
+
backup_path = self.undo.backup_file(filepath)
|
|
306
|
+
|
|
307
|
+
# Get content for operation log
|
|
308
|
+
content = filepath.read_text()
|
|
309
|
+
|
|
310
|
+
# Delete the file
|
|
311
|
+
filepath.unlink()
|
|
312
|
+
|
|
313
|
+
# Log the operation
|
|
314
|
+
self.undo.log_file_operation("delete", filepath, old_content=content)
|
|
315
|
+
|
|
316
|
+
# Commit to git
|
|
317
|
+
self.undo.git_commit(f"Deleted {filepath.name}")
|
|
318
|
+
|
|
319
|
+
return True, f"File deleted (backup at {backup_path.name})"
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return False, f"Failed to delete file: {e}"
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project-local undo service that stores backups in the working directory.
|
|
3
|
+
Designed for pip-installed CLI tool usage.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from tunacode.core.state import StateManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProjectUndoService:
|
|
17
|
+
"""Undo system that stores backups in .tunacode/ within the project."""
|
|
18
|
+
|
|
19
|
+
# Directory name for local undo data
|
|
20
|
+
UNDO_DIR_NAME = ".tunacode"
|
|
21
|
+
BACKUP_SUBDIR = "backups"
|
|
22
|
+
GITIGNORE_CONTENT = """# TunaCode undo system files
|
|
23
|
+
*
|
|
24
|
+
!.gitignore
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, state_manager: StateManager, project_dir: Optional[Path] = None):
|
|
28
|
+
self.state_manager = state_manager
|
|
29
|
+
self.project_dir = Path(project_dir or Path.cwd())
|
|
30
|
+
|
|
31
|
+
# Local undo directory in the project
|
|
32
|
+
self.undo_dir = self.project_dir / self.UNDO_DIR_NAME
|
|
33
|
+
self.backup_dir = self.undo_dir / self.BACKUP_SUBDIR
|
|
34
|
+
self.op_log_file = self.undo_dir / "operations.jsonl"
|
|
35
|
+
|
|
36
|
+
# Git directory (if project uses git)
|
|
37
|
+
self.git_dir = self.project_dir / ".git"
|
|
38
|
+
|
|
39
|
+
self._init_undo_directory()
|
|
40
|
+
|
|
41
|
+
def _init_undo_directory(self):
|
|
42
|
+
"""Initialize the .tunacode directory in the project."""
|
|
43
|
+
try:
|
|
44
|
+
# Create directories
|
|
45
|
+
self.undo_dir.mkdir(exist_ok=True)
|
|
46
|
+
self.backup_dir.mkdir(exist_ok=True)
|
|
47
|
+
|
|
48
|
+
# Create .gitignore to exclude undo files from version control
|
|
49
|
+
gitignore_path = self.undo_dir / ".gitignore"
|
|
50
|
+
if not gitignore_path.exists():
|
|
51
|
+
gitignore_path.write_text(self.GITIGNORE_CONTENT)
|
|
52
|
+
|
|
53
|
+
# Create operation log
|
|
54
|
+
if not self.op_log_file.exists():
|
|
55
|
+
self.op_log_file.touch()
|
|
56
|
+
|
|
57
|
+
# Add informational README
|
|
58
|
+
readme_path = self.undo_dir / "README.md"
|
|
59
|
+
if not readme_path.exists():
|
|
60
|
+
readme_path.write_text(
|
|
61
|
+
"""# TunaCode Undo System
|
|
62
|
+
|
|
63
|
+
This directory contains local backup files created by TunaCode to enable undo functionality.
|
|
64
|
+
|
|
65
|
+
## Contents:
|
|
66
|
+
- `backups/` - Timestamped file backups
|
|
67
|
+
- `operations.jsonl` - Operation history log
|
|
68
|
+
- `.gitignore` - Excludes these files from git
|
|
69
|
+
|
|
70
|
+
## Notes:
|
|
71
|
+
- These files are local to your machine
|
|
72
|
+
- Safe to delete if you don't need undo history
|
|
73
|
+
- Automatically cleaned up (keeps last 50 backups)
|
|
74
|
+
- Not committed to version control
|
|
75
|
+
|
|
76
|
+
Created by TunaCode (https://github.com/larock22/tunacode)
|
|
77
|
+
"""
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
except PermissionError:
|
|
81
|
+
print(f"⚠️ Cannot create {self.UNDO_DIR_NAME}/ directory - undo will be limited")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
print(f"⚠️ Error initializing undo directory: {e}")
|
|
84
|
+
|
|
85
|
+
def should_track_file(self, filepath: Path) -> bool:
|
|
86
|
+
"""Check if a file should be tracked by undo system."""
|
|
87
|
+
# Don't track files in our own undo directory
|
|
88
|
+
try:
|
|
89
|
+
if self.undo_dir in filepath.parents:
|
|
90
|
+
return False
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
# Don't track hidden files/directories (except .gitignore, .env, etc)
|
|
95
|
+
parts = filepath.parts
|
|
96
|
+
for part in parts:
|
|
97
|
+
if part.startswith(".") and part not in {".gitignore", ".env", ".envrc"}:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Don't track common build/cache directories
|
|
101
|
+
exclude_dirs = {
|
|
102
|
+
"node_modules",
|
|
103
|
+
"__pycache__",
|
|
104
|
+
".pytest_cache",
|
|
105
|
+
"dist",
|
|
106
|
+
"build",
|
|
107
|
+
".next",
|
|
108
|
+
".nuxt",
|
|
109
|
+
"target",
|
|
110
|
+
}
|
|
111
|
+
if any(excluded in parts for excluded in exclude_dirs):
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def backup_file(self, filepath: Path) -> Optional[Path]:
|
|
117
|
+
"""Create a timestamped backup of a file."""
|
|
118
|
+
# Check if we should track this file
|
|
119
|
+
if not self.should_track_file(filepath):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Use relative path for organizing backups
|
|
124
|
+
rel_path = filepath.relative_to(self.project_dir)
|
|
125
|
+
|
|
126
|
+
# Create subdirectories in backup dir to mirror project structure
|
|
127
|
+
backup_subdir = self.backup_dir / rel_path.parent
|
|
128
|
+
backup_subdir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# Create backup filename with timestamp
|
|
131
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
132
|
+
backup_name = f"{filepath.name}.{timestamp}.bak"
|
|
133
|
+
backup_path = backup_subdir / backup_name
|
|
134
|
+
|
|
135
|
+
# Copy the file if it exists
|
|
136
|
+
if filepath.exists():
|
|
137
|
+
shutil.copy2(filepath, backup_path)
|
|
138
|
+
else:
|
|
139
|
+
# Create empty backup for new files
|
|
140
|
+
backup_path.touch()
|
|
141
|
+
|
|
142
|
+
# Log the backup
|
|
143
|
+
self._log_operation(
|
|
144
|
+
{
|
|
145
|
+
"type": "backup",
|
|
146
|
+
"file": str(rel_path),
|
|
147
|
+
"backup": str(backup_path.relative_to(self.undo_dir)),
|
|
148
|
+
"timestamp": datetime.now().isoformat(),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Clean up old backups for this file
|
|
153
|
+
self._cleanup_file_backups(backup_subdir, filepath.name, keep=10)
|
|
154
|
+
|
|
155
|
+
return backup_path
|
|
156
|
+
|
|
157
|
+
except Exception:
|
|
158
|
+
# Silent fail - don't interrupt user's work
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _cleanup_file_backups(self, backup_dir: Path, filename: str, keep: int = 10):
|
|
162
|
+
"""Keep only the most recent backups for a specific file."""
|
|
163
|
+
try:
|
|
164
|
+
# Find all backups for this file
|
|
165
|
+
pattern = f"{filename}.*.bak"
|
|
166
|
+
backups = sorted(backup_dir.glob(pattern), key=lambda p: p.stat().st_mtime)
|
|
167
|
+
|
|
168
|
+
# Remove old backups
|
|
169
|
+
if len(backups) > keep:
|
|
170
|
+
for old_backup in backups[:-keep]:
|
|
171
|
+
old_backup.unlink()
|
|
172
|
+
|
|
173
|
+
except Exception:
|
|
174
|
+
pass # Silent cleanup failure
|
|
175
|
+
|
|
176
|
+
def _log_operation(self, operation: Dict):
|
|
177
|
+
"""Log an operation to the operations file."""
|
|
178
|
+
try:
|
|
179
|
+
with open(self.op_log_file, "a") as f:
|
|
180
|
+
json.dump(operation, f)
|
|
181
|
+
f.write("\n")
|
|
182
|
+
|
|
183
|
+
# Keep log file size reasonable (last 1000 operations)
|
|
184
|
+
self._trim_log_file(1000)
|
|
185
|
+
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def _trim_log_file(self, max_lines: int):
|
|
190
|
+
"""Keep only the last N operations in the log."""
|
|
191
|
+
try:
|
|
192
|
+
with open(self.op_log_file, "r") as f:
|
|
193
|
+
lines = f.readlines()
|
|
194
|
+
|
|
195
|
+
if len(lines) > max_lines:
|
|
196
|
+
with open(self.op_log_file, "w") as f:
|
|
197
|
+
f.writelines(lines[-max_lines:])
|
|
198
|
+
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
def get_undo_status(self) -> Dict[str, any]:
|
|
203
|
+
"""Get current status of undo system."""
|
|
204
|
+
try:
|
|
205
|
+
backup_count = sum(1 for _ in self.backup_dir.rglob("*.bak"))
|
|
206
|
+
backup_size = sum(f.stat().st_size for f in self.backup_dir.rglob("*.bak"))
|
|
207
|
+
log_size = self.op_log_file.stat().st_size if self.op_log_file.exists() else 0
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"enabled": True,
|
|
211
|
+
"location": str(self.undo_dir),
|
|
212
|
+
"backup_count": backup_count,
|
|
213
|
+
"backup_size_mb": backup_size / (1024 * 1024),
|
|
214
|
+
"log_size_kb": log_size / 1024,
|
|
215
|
+
"git_available": self.git_dir.exists(),
|
|
216
|
+
}
|
|
217
|
+
except Exception:
|
|
218
|
+
return {"enabled": False, "error": "Cannot access undo directory"}
|
|
219
|
+
|
|
220
|
+
def cleanup_old_backups(self, days: int = 7):
|
|
221
|
+
"""Remove backups older than specified days."""
|
|
222
|
+
try:
|
|
223
|
+
cutoff_time = time.time() - (days * 24 * 60 * 60)
|
|
224
|
+
|
|
225
|
+
for backup in self.backup_dir.rglob("*.bak"):
|
|
226
|
+
if backup.stat().st_mtime < cutoff_time:
|
|
227
|
+
backup.unlink()
|
|
228
|
+
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ProjectSafeFileOperations:
|
|
234
|
+
"""File operations with project-local backup."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, undo_service: ProjectUndoService):
|
|
237
|
+
self.undo = undo_service
|
|
238
|
+
|
|
239
|
+
async def safe_write(self, filepath: Path, content: str) -> Tuple[bool, str]:
|
|
240
|
+
"""Write file with automatic local backup."""
|
|
241
|
+
filepath = Path(filepath).resolve()
|
|
242
|
+
|
|
243
|
+
# Create backup before write
|
|
244
|
+
backup_path = self.undo.backup_file(filepath)
|
|
245
|
+
|
|
246
|
+
# Check if file exists for logging
|
|
247
|
+
if filepath.exists() and filepath.stat().st_size < 100_000:
|
|
248
|
+
try:
|
|
249
|
+
filepath.read_text() # Just check it's readable
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
# Write the file
|
|
254
|
+
try:
|
|
255
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
filepath.write_text(content)
|
|
257
|
+
|
|
258
|
+
# Log the operation
|
|
259
|
+
self.undo._log_operation(
|
|
260
|
+
{
|
|
261
|
+
"type": "write",
|
|
262
|
+
"file": str(filepath.relative_to(self.undo.project_dir)),
|
|
263
|
+
"size": len(content),
|
|
264
|
+
"backup": (
|
|
265
|
+
str(backup_path.relative_to(self.undo.undo_dir)) if backup_path else None
|
|
266
|
+
),
|
|
267
|
+
"timestamp": datetime.now().isoformat(),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return True, "File written successfully"
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
return False, f"Failed to write file: {e}"
|
|
275
|
+
|
|
276
|
+
async def safe_delete(self, filepath: Path) -> Tuple[bool, str]:
|
|
277
|
+
"""Delete file with automatic local backup."""
|
|
278
|
+
filepath = Path(filepath).resolve()
|
|
279
|
+
|
|
280
|
+
if not filepath.exists():
|
|
281
|
+
return False, "File does not exist"
|
|
282
|
+
|
|
283
|
+
# Create backup before delete
|
|
284
|
+
backup_path = self.undo.backup_file(filepath)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
filepath.unlink()
|
|
288
|
+
|
|
289
|
+
# Log the operation
|
|
290
|
+
self.undo._log_operation(
|
|
291
|
+
{
|
|
292
|
+
"type": "delete",
|
|
293
|
+
"file": str(filepath.relative_to(self.undo.project_dir)),
|
|
294
|
+
"backup": (
|
|
295
|
+
str(backup_path.relative_to(self.undo.undo_dir)) if backup_path else None
|
|
296
|
+
),
|
|
297
|
+
"timestamp": datetime.now().isoformat(),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return True, f"File deleted (backup available in {self.undo.UNDO_DIR_NAME}/)"
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return False, f"Failed to delete file: {e}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_project_undo_service(state_manager: StateManager) -> ProjectUndoService:
|
|
308
|
+
"""Get or create project-local undo service."""
|
|
309
|
+
if not hasattr(state_manager.session, "project_undo"):
|
|
310
|
+
state_manager.session.project_undo = ProjectUndoService(state_manager)
|
|
311
|
+
return state_manager.session.project_undo
|