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.
Files changed (45) hide show
  1. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +368 -384
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +999 -142
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.5.dist-info/RECORD +0 -28
  38. aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -231
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -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