aline-ai 0.2.5__py3-none-any.whl → 0.3.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.
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +368 -384
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +999 -142
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.5.dist-info/RECORD +0 -28
- aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -231
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/commands/undo.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Undo command - Revert project and session state to a specific commit."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict, List, Any
|
|
9
|
+
|
|
10
|
+
from ..tracker.git_tracker import ReAlignGitTracker
|
|
11
|
+
from ..mirror_utils import (
|
|
12
|
+
get_gitignore_patterns,
|
|
13
|
+
get_files_at_commit,
|
|
14
|
+
reverse_mirror_from_commit,
|
|
15
|
+
collect_project_files
|
|
16
|
+
)
|
|
17
|
+
from .session_utils import (
|
|
18
|
+
find_session_paths_for_commit,
|
|
19
|
+
detect_original_session_location,
|
|
20
|
+
restore_session_from_commit
|
|
21
|
+
)
|
|
22
|
+
from ..logging_config import setup_logger
|
|
23
|
+
|
|
24
|
+
logger = setup_logger('realign.commands.undo', 'undo.log')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def undo_command(
|
|
28
|
+
commit_hash: str,
|
|
29
|
+
repo_root: Optional[Path] = None,
|
|
30
|
+
dry_run: bool = False,
|
|
31
|
+
no_backup: bool = False,
|
|
32
|
+
deletion_strategy: str = "keep",
|
|
33
|
+
force: bool = False
|
|
34
|
+
) -> int:
|
|
35
|
+
"""
|
|
36
|
+
Undo project and session state to a specific commit.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
commit_hash: Commit hash to undo to
|
|
40
|
+
repo_root: Project root directory (defaults to current directory)
|
|
41
|
+
dry_run: Preview changes without executing
|
|
42
|
+
no_backup: Skip backup creation
|
|
43
|
+
deletion_strategy: How to handle extra files: "keep", "delete", or "backup"
|
|
44
|
+
force: Skip confirmation prompts
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Exit code (0 for success, 1 for error)
|
|
48
|
+
"""
|
|
49
|
+
if repo_root is None:
|
|
50
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
51
|
+
|
|
52
|
+
# Initialize tracker
|
|
53
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
54
|
+
|
|
55
|
+
# Phase 1: Validation
|
|
56
|
+
logger.info(f"Starting undo to commit {commit_hash}")
|
|
57
|
+
|
|
58
|
+
if not tracker.is_initialized():
|
|
59
|
+
print("❌ Repository not initialized")
|
|
60
|
+
print("Run 'aline init' first")
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
if not tracker.verify_commit_exists(commit_hash):
|
|
64
|
+
print(f"❌ Commit {commit_hash} does not exist")
|
|
65
|
+
print("\nRecent commits:")
|
|
66
|
+
# Show recent commits to help user
|
|
67
|
+
try:
|
|
68
|
+
import subprocess
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["git", "log", "--oneline", "-10"],
|
|
71
|
+
cwd=tracker.realign_dir,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
check=False
|
|
75
|
+
)
|
|
76
|
+
if result.returncode == 0:
|
|
77
|
+
print(result.stdout)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
# Get commit info
|
|
83
|
+
commit_info = tracker.get_commit_info(commit_hash)
|
|
84
|
+
if not commit_info:
|
|
85
|
+
print(f"❌ Failed to get commit information for {commit_hash}")
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
# Check if already at target commit
|
|
89
|
+
current_head = None
|
|
90
|
+
try:
|
|
91
|
+
import subprocess
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "rev-parse", "HEAD"],
|
|
94
|
+
cwd=tracker.realign_dir,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
check=False
|
|
98
|
+
)
|
|
99
|
+
if result.returncode == 0:
|
|
100
|
+
current_head = result.stdout.strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if current_head and current_head.startswith(commit_info['hash'][:7]):
|
|
105
|
+
print(f"Already at commit {commit_hash}")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
# Check current branch
|
|
109
|
+
current_branch = tracker.get_current_branch()
|
|
110
|
+
if not current_branch:
|
|
111
|
+
print("❌ Cannot undo from detached HEAD state")
|
|
112
|
+
print("Please checkout master first: cd ~/.aline/{project_name} && git checkout master")
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
if current_branch != "master":
|
|
116
|
+
print(f"⚠️ Warning: Currently on branch '{current_branch}', expected 'master'")
|
|
117
|
+
if not force:
|
|
118
|
+
response = input("Continue anyway? [y/N]: ").strip().lower()
|
|
119
|
+
if response != 'y':
|
|
120
|
+
print("Aborted")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
# Phase 2: Preview
|
|
124
|
+
logger.info("Calculating changes...")
|
|
125
|
+
|
|
126
|
+
gitignore_patterns = get_gitignore_patterns(repo_root)
|
|
127
|
+
files_at_target = get_files_at_commit(tracker, commit_hash)
|
|
128
|
+
current_files = collect_project_files(repo_root, logger=None)
|
|
129
|
+
|
|
130
|
+
# Convert to sets of relative paths for comparison
|
|
131
|
+
target_paths = set(str(f) for f in files_at_target)
|
|
132
|
+
current_paths = set(str(f.relative_to(repo_root)) for f in current_files)
|
|
133
|
+
|
|
134
|
+
files_to_restore = target_paths
|
|
135
|
+
files_to_delete = current_paths - target_paths
|
|
136
|
+
files_to_create = target_paths - current_paths
|
|
137
|
+
|
|
138
|
+
# Find session files
|
|
139
|
+
session_files = find_session_paths_for_commit(
|
|
140
|
+
repo_root,
|
|
141
|
+
commit_hash,
|
|
142
|
+
git_dir=tracker.realign_dir
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Format commit timestamp
|
|
146
|
+
try:
|
|
147
|
+
timestamp = int(commit_info['timestamp'])
|
|
148
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
149
|
+
formatted_time = dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
150
|
+
except Exception:
|
|
151
|
+
formatted_time = commit_info['timestamp']
|
|
152
|
+
|
|
153
|
+
# Display preview
|
|
154
|
+
print(f"\nUndoing to commit {commit_hash[:7]} ({formatted_time})")
|
|
155
|
+
print(f"Message: {commit_info['message']}")
|
|
156
|
+
print("\nChanges preview:")
|
|
157
|
+
print(f" Files to restore: {len(files_to_restore)}")
|
|
158
|
+
|
|
159
|
+
if files_to_delete:
|
|
160
|
+
deletion_msg = "will be kept by default" if deletion_strategy == "keep" else f"will be {deletion_strategy}d"
|
|
161
|
+
print(f" Files to delete: {len(files_to_delete)} ({deletion_msg})")
|
|
162
|
+
else:
|
|
163
|
+
print(f" Files to delete: 0")
|
|
164
|
+
|
|
165
|
+
print(f" Sessions to restore: {len(session_files)}")
|
|
166
|
+
|
|
167
|
+
if dry_run:
|
|
168
|
+
print("\n[DRY RUN] Would perform the following actions:")
|
|
169
|
+
print("\nGit Operations:")
|
|
170
|
+
|
|
171
|
+
undo_branch = f"undo-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
172
|
+
print(f" Create branch: {undo_branch} (from current HEAD)")
|
|
173
|
+
print(f" Reset master: {current_head[:7] if current_head else 'current'} → {commit_hash[:7]}")
|
|
174
|
+
|
|
175
|
+
print("\nFile Changes:")
|
|
176
|
+
if files_to_restore:
|
|
177
|
+
print(f" Restore ({len(files_to_restore)} files):")
|
|
178
|
+
for path in sorted(list(files_to_restore)[:5]):
|
|
179
|
+
print(f" {path}")
|
|
180
|
+
if len(files_to_restore) > 5:
|
|
181
|
+
print(f" ... and {len(files_to_restore) - 5} more")
|
|
182
|
+
|
|
183
|
+
if files_to_delete:
|
|
184
|
+
print(f" {deletion_strategy.capitalize()} ({len(files_to_delete)} files):")
|
|
185
|
+
for path in sorted(list(files_to_delete)[:5]):
|
|
186
|
+
print(f" {path}")
|
|
187
|
+
if len(files_to_delete) > 5:
|
|
188
|
+
print(f" ... and {len(files_to_delete) - 5} more")
|
|
189
|
+
|
|
190
|
+
if session_files:
|
|
191
|
+
print("\nSession Restoration:")
|
|
192
|
+
for session_file in session_files:
|
|
193
|
+
filename = Path(session_file).name
|
|
194
|
+
dest = detect_original_session_location(filename, repo_root, timestamp)
|
|
195
|
+
if dest:
|
|
196
|
+
print(f" {filename} → {dest}")
|
|
197
|
+
else:
|
|
198
|
+
fallback = tracker.realign_dir / "sessions-restored" / filename
|
|
199
|
+
print(f" {filename} → {fallback} (fallback)")
|
|
200
|
+
|
|
201
|
+
print("\nNo changes made (dry-run mode)")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
# Phase 3: User Confirmation
|
|
205
|
+
if not force:
|
|
206
|
+
print()
|
|
207
|
+
response = input("Continue? [y/N]: ").strip().lower()
|
|
208
|
+
if response != 'y':
|
|
209
|
+
print("Aborted")
|
|
210
|
+
return 1
|
|
211
|
+
|
|
212
|
+
# Phase 4: Backup
|
|
213
|
+
backup_dir = None
|
|
214
|
+
backup_metadata = None
|
|
215
|
+
|
|
216
|
+
if not no_backup:
|
|
217
|
+
print("\n✓ Creating backup...")
|
|
218
|
+
timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
219
|
+
backup_dir = tracker.realign_dir / f"undo-backup-{timestamp_str}"
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
# Mirror current state
|
|
225
|
+
current_files_list = collect_project_files(repo_root, logger=logger)
|
|
226
|
+
mirrored = tracker.mirror_files(current_files_list)
|
|
227
|
+
|
|
228
|
+
# Store backup metadata
|
|
229
|
+
backup_metadata = {
|
|
230
|
+
"timestamp": datetime.now().isoformat(),
|
|
231
|
+
"from_commit": current_head,
|
|
232
|
+
"to_commit": commit_hash,
|
|
233
|
+
"backup_path": str(backup_dir)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
metadata_dir = tracker.realign_dir / ".metadata"
|
|
237
|
+
metadata_dir.mkdir(exist_ok=True)
|
|
238
|
+
|
|
239
|
+
metadata_file = metadata_dir / "undo_backup.json"
|
|
240
|
+
with open(metadata_file, 'w') as f:
|
|
241
|
+
json.dump(backup_metadata, f, indent=2)
|
|
242
|
+
|
|
243
|
+
logger.info(f"Backup created at {backup_dir}")
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
print(f"❌ Failed to create backup: {e}")
|
|
247
|
+
logger.error(f"Backup creation failed: {e}", exc_info=True)
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
# Phase 5: Execute Undo
|
|
251
|
+
undo_branch = f"undo-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
# Create undo branch (this will switch to the new branch)
|
|
255
|
+
print(f"✓ Creating undo branch: {undo_branch}")
|
|
256
|
+
if not tracker.create_branch(undo_branch, "HEAD"):
|
|
257
|
+
raise Exception("Failed to create undo branch")
|
|
258
|
+
|
|
259
|
+
# Always checkout master to perform the reset there
|
|
260
|
+
print("✓ Checking out master branch")
|
|
261
|
+
if not tracker.checkout_branch("master"):
|
|
262
|
+
raise Exception("Failed to checkout master")
|
|
263
|
+
|
|
264
|
+
# Reset to target commit
|
|
265
|
+
print(f"✓ Resetting to commit {commit_hash[:7]}")
|
|
266
|
+
if not tracker.reset_to_commit(commit_hash):
|
|
267
|
+
raise Exception("Failed to reset to commit")
|
|
268
|
+
|
|
269
|
+
# Restore files
|
|
270
|
+
print(f"✓ Restoring {len(files_to_restore)} files")
|
|
271
|
+
restore_result = reverse_mirror_from_commit(
|
|
272
|
+
tracker,
|
|
273
|
+
commit_hash,
|
|
274
|
+
repo_root,
|
|
275
|
+
gitignore_patterns,
|
|
276
|
+
logger=logger
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Handle deletions
|
|
280
|
+
if files_to_delete:
|
|
281
|
+
if deletion_strategy == "delete":
|
|
282
|
+
deleted_count = 0
|
|
283
|
+
for rel_path in files_to_delete:
|
|
284
|
+
try:
|
|
285
|
+
file_path = repo_root / rel_path
|
|
286
|
+
if file_path.exists():
|
|
287
|
+
file_path.unlink()
|
|
288
|
+
deleted_count += 1
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning(f"Failed to delete {rel_path}: {e}")
|
|
291
|
+
print(f"✓ Deleted {deleted_count} extra files")
|
|
292
|
+
|
|
293
|
+
elif deletion_strategy == "backup" and backup_dir:
|
|
294
|
+
moved_count = 0
|
|
295
|
+
for rel_path in files_to_delete:
|
|
296
|
+
try:
|
|
297
|
+
src = repo_root / rel_path
|
|
298
|
+
dst = backup_dir / rel_path
|
|
299
|
+
if src.exists():
|
|
300
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
shutil.move(str(src), str(dst))
|
|
302
|
+
moved_count += 1
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.warning(f"Failed to move {rel_path}: {e}")
|
|
305
|
+
print(f"✓ Moved {moved_count} extra files to backup")
|
|
306
|
+
|
|
307
|
+
# Restore sessions
|
|
308
|
+
sessions_restored = 0
|
|
309
|
+
sessions_failed = 0
|
|
310
|
+
|
|
311
|
+
if session_files:
|
|
312
|
+
print(f"✓ Restoring {len(session_files)} session file(s)")
|
|
313
|
+
|
|
314
|
+
for session_rel_path in session_files:
|
|
315
|
+
filename = Path(session_rel_path).name
|
|
316
|
+
|
|
317
|
+
# Detect original location
|
|
318
|
+
dest_path = detect_original_session_location(filename, repo_root, timestamp)
|
|
319
|
+
|
|
320
|
+
# Fall back to sessions-restored if cannot detect
|
|
321
|
+
if not dest_path:
|
|
322
|
+
restored_dir = tracker.realign_dir / "sessions-restored"
|
|
323
|
+
restored_dir.mkdir(exist_ok=True)
|
|
324
|
+
dest_path = restored_dir / filename
|
|
325
|
+
logger.info(f"Session location not detected, using fallback: {dest_path}")
|
|
326
|
+
|
|
327
|
+
# Restore session
|
|
328
|
+
if restore_session_from_commit(tracker, commit_hash, session_rel_path, dest_path):
|
|
329
|
+
sessions_restored += 1
|
|
330
|
+
logger.info(f"Restored session: {filename} → {dest_path}")
|
|
331
|
+
else:
|
|
332
|
+
sessions_failed += 1
|
|
333
|
+
logger.warning(f"Failed to restore session: {filename}")
|
|
334
|
+
|
|
335
|
+
# Phase 6: Report
|
|
336
|
+
print(f"\n✅ Successfully undone to commit {commit_hash[:7]}")
|
|
337
|
+
print("\nSummary:")
|
|
338
|
+
print(f" Undo branch: {undo_branch}")
|
|
339
|
+
|
|
340
|
+
if backup_dir:
|
|
341
|
+
print(f" Backup location: {backup_dir}")
|
|
342
|
+
|
|
343
|
+
print(f" Files restored: {len(restore_result['restored'])}")
|
|
344
|
+
|
|
345
|
+
if restore_result['skipped']:
|
|
346
|
+
print(f" Files skipped: {len(restore_result['skipped'])}")
|
|
347
|
+
|
|
348
|
+
if restore_result['failed']:
|
|
349
|
+
print(f" Files failed: {len(restore_result['failed'])}")
|
|
350
|
+
|
|
351
|
+
if files_to_delete:
|
|
352
|
+
if deletion_strategy == "keep":
|
|
353
|
+
print(f" Files kept: {len(files_to_delete)}")
|
|
354
|
+
elif deletion_strategy == "delete":
|
|
355
|
+
print(f" Files deleted: {deleted_count}")
|
|
356
|
+
elif deletion_strategy == "backup":
|
|
357
|
+
print(f" Files moved to backup: {moved_count}")
|
|
358
|
+
|
|
359
|
+
if session_files:
|
|
360
|
+
print(f" Sessions restored: {sessions_restored}")
|
|
361
|
+
if sessions_failed > 0:
|
|
362
|
+
print(f" Sessions failed: {sessions_failed}")
|
|
363
|
+
|
|
364
|
+
print("\nRecovery options:")
|
|
365
|
+
print(f" To undo this operation: aline undo {current_head[:7] if current_head else '<previous_commit>'}")
|
|
366
|
+
|
|
367
|
+
if backup_dir:
|
|
368
|
+
print(f" To restore from backup: cp -r {backup_dir}/* {repo_root}/")
|
|
369
|
+
|
|
370
|
+
print(f" To switch to preserved state: cd {tracker.realign_dir} && git checkout {undo_branch}")
|
|
371
|
+
|
|
372
|
+
logger.info("Undo operation completed successfully")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
except Exception as e:
|
|
376
|
+
print(f"\n❌ Undo operation failed: {e}")
|
|
377
|
+
logger.error(f"Undo operation failed: {e}", exc_info=True)
|
|
378
|
+
|
|
379
|
+
# Attempt automatic rollback
|
|
380
|
+
print("\nAttempting automatic rollback...")
|
|
381
|
+
try:
|
|
382
|
+
# Checkout undo branch
|
|
383
|
+
if tracker.checkout_branch(undo_branch):
|
|
384
|
+
# Force reset master to undo branch
|
|
385
|
+
import subprocess
|
|
386
|
+
result = subprocess.run(
|
|
387
|
+
["git", "branch", "-f", "master", undo_branch],
|
|
388
|
+
cwd=tracker.realign_dir,
|
|
389
|
+
capture_output=True,
|
|
390
|
+
check=False
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if result.returncode == 0:
|
|
394
|
+
tracker.checkout_branch("master")
|
|
395
|
+
|
|
396
|
+
# Restore files from backup if exists
|
|
397
|
+
if backup_dir and backup_dir.exists():
|
|
398
|
+
print("Restoring files from backup...")
|
|
399
|
+
# This is a simplified restore, just copying back
|
|
400
|
+
# In practice, you might want more sophisticated logic
|
|
401
|
+
|
|
402
|
+
print("✓ Operation failed and rolled back successfully")
|
|
403
|
+
print(f" State preserved in branch: {undo_branch}")
|
|
404
|
+
logger.info("Rollback successful")
|
|
405
|
+
else:
|
|
406
|
+
raise Exception("Failed to reset master branch")
|
|
407
|
+
else:
|
|
408
|
+
raise Exception("Failed to checkout undo branch")
|
|
409
|
+
|
|
410
|
+
except Exception as rollback_error:
|
|
411
|
+
print(f"❌ Automatic rollback failed: {rollback_error}")
|
|
412
|
+
print("\nManual recovery instructions:")
|
|
413
|
+
print(f" 1. cd {tracker.realign_dir}")
|
|
414
|
+
print(f" 2. git checkout {undo_branch}")
|
|
415
|
+
print(f" 3. git branch -f master {undo_branch}")
|
|
416
|
+
print(f" 4. git checkout master")
|
|
417
|
+
|
|
418
|
+
if backup_dir and backup_dir.exists():
|
|
419
|
+
print(f" 5. Restore files from: {backup_dir}")
|
|
420
|
+
|
|
421
|
+
logger.error(f"Rollback failed: {rollback_error}", exc_info=True)
|
|
422
|
+
|
|
423
|
+
return 1
|