aline-ai 0.2.6__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.6.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 +115 -411
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +997 -139
  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.6.dist-info/RECORD +0 -28
  38. aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -242
  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.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env python3
2
+ """ReAlign status command - Display system status and activity."""
3
+
4
+ import subprocess
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ from ..config import ReAlignConfig
14
+ from ..hooks import find_all_active_sessions
15
+ from ..logging_config import setup_logger
16
+ from .review import get_unpushed_commits
17
+
18
+ # Initialize logger
19
+ logger = setup_logger('realign.status', 'status.log')
20
+ console = Console()
21
+
22
+
23
+ # ============================================================================
24
+ # Data Collection Functions
25
+ # ============================================================================
26
+
27
+ def check_initialization_status() -> Dict:
28
+ """
29
+ Check if ReAlign is initialized in current directory.
30
+
31
+ Returns:
32
+ dict: Initialization status with paths and commit count
33
+ """
34
+ try:
35
+ realign_path = Path.cwd() / '.realign'
36
+ if not realign_path.exists():
37
+ return {
38
+ "initialized": False,
39
+ "realign_path": None,
40
+ "project_path": None,
41
+ "total_commits": 0,
42
+ "has_git_mirror": False
43
+ }
44
+
45
+ # Check for git mirror
46
+ git_path = realign_path / '.git'
47
+ has_git_mirror = git_path.exists()
48
+
49
+ # Try to count commits
50
+ total_commits = 0
51
+ if has_git_mirror:
52
+ try:
53
+ result = subprocess.run(
54
+ ['git', '-C', str(git_path), 'rev-list', '--count', 'HEAD'],
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=2
58
+ )
59
+ if result.returncode == 0:
60
+ total_commits = int(result.stdout.strip())
61
+ except (subprocess.TimeoutExpired, ValueError, Exception) as e:
62
+ logger.warning(f"Failed to count commits: {e}")
63
+
64
+ return {
65
+ "initialized": True,
66
+ "realign_path": realign_path,
67
+ "project_path": Path.cwd(),
68
+ "total_commits": total_commits,
69
+ "has_git_mirror": has_git_mirror
70
+ }
71
+
72
+ except Exception as e:
73
+ logger.error(f"Error checking initialization: {e}", exc_info=True)
74
+ return {
75
+ "initialized": False,
76
+ "realign_path": None,
77
+ "project_path": None,
78
+ "total_commits": 0,
79
+ "has_git_mirror": False
80
+ }
81
+
82
+
83
+ def detect_watcher_status() -> Dict:
84
+ """
85
+ Detect if MCP watcher is running.
86
+
87
+ Uses multi-method detection:
88
+ 1. Check for aline-mcp process (ps aux)
89
+ 2. Check log freshness (< 5 mins = active)
90
+
91
+ Returns:
92
+ dict: Watcher status and configuration
93
+ """
94
+ try:
95
+ # Load configuration
96
+ config = ReAlignConfig.load()
97
+
98
+ # Step 1: Check for process
99
+ is_process_running = False
100
+ pid = None
101
+ try:
102
+ ps_output = subprocess.run(
103
+ ['ps', 'aux'],
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=2
107
+ )
108
+ if ps_output.returncode == 0:
109
+ for line in ps_output.stdout.split('\n'):
110
+ if 'aline-mcp' in line:
111
+ is_process_running = True
112
+ # Try to extract PID (second column)
113
+ parts = line.split()
114
+ if len(parts) > 1:
115
+ try:
116
+ pid = int(parts[1])
117
+ except ValueError:
118
+ pass
119
+ break
120
+ except subprocess.TimeoutExpired:
121
+ logger.warning("Process check timed out")
122
+
123
+ # Step 2: Check log freshness
124
+ log_path = Path.home() / '.aline/.logs/mcp_watcher.log'
125
+ is_log_active = False
126
+ last_activity = None
127
+
128
+ if log_path.exists():
129
+ try:
130
+ last_modified = datetime.fromtimestamp(log_path.stat().st_mtime)
131
+ seconds_since_modified = (datetime.now() - last_modified).total_seconds()
132
+ is_log_active = seconds_since_modified < 300 # 5 mins
133
+ last_activity = last_modified
134
+ except Exception as e:
135
+ logger.warning(f"Failed to check log timestamp: {e}")
136
+
137
+ # Step 3: Determine status
138
+ if is_process_running and is_log_active:
139
+ status = "Running"
140
+ elif not is_process_running:
141
+ status = "Stopped"
142
+ else:
143
+ status = "Inactive" # Process exists but log is stale
144
+
145
+ return {
146
+ "status": status,
147
+ "pid": pid,
148
+ "auto_commit_enabled": config.mcp_auto_commit,
149
+ "debounce_delay": 2.0, # Hardcoded from mcp_watcher.py
150
+ "cooldown": 5.0, # Hardcoded from mcp_watcher.py
151
+ "last_activity": last_activity
152
+ }
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error detecting watcher status: {e}", exc_info=True)
156
+ return {
157
+ "status": "Unknown",
158
+ "pid": None,
159
+ "auto_commit_enabled": False,
160
+ "debounce_delay": 2.0,
161
+ "cooldown": 5.0,
162
+ "last_activity": None
163
+ }
164
+
165
+
166
+ def get_session_information() -> Dict:
167
+ """
168
+ Detect active sessions from Claude Code and Codex.
169
+
170
+ Returns:
171
+ dict: Session paths and latest session info
172
+ """
173
+ try:
174
+ config = ReAlignConfig.load()
175
+ project_path = Path.cwd()
176
+
177
+ # Find all active sessions
178
+ all_sessions = find_all_active_sessions(config, project_path)
179
+
180
+ # Separate by type (Claude vs Codex)
181
+ claude_sessions = []
182
+ codex_sessions = []
183
+
184
+ for session in all_sessions:
185
+ session_str = str(session)
186
+ if '.claude/projects/' in session_str:
187
+ claude_sessions.append(session)
188
+ elif '.codex/sessions/' in session_str:
189
+ codex_sessions.append(session)
190
+ else:
191
+ # Default to Claude if unclear
192
+ claude_sessions.append(session)
193
+
194
+ # Get latest session
195
+ latest_session = None
196
+ latest_session_time = None
197
+
198
+ if all_sessions:
199
+ try:
200
+ latest_session = max(all_sessions, key=lambda f: f.stat().st_mtime)
201
+ latest_session_time = datetime.fromtimestamp(latest_session.stat().st_mtime)
202
+ except Exception as e:
203
+ logger.warning(f"Failed to determine latest session: {e}")
204
+
205
+ return {
206
+ "claude_sessions": claude_sessions,
207
+ "codex_sessions": codex_sessions,
208
+ "latest_session": latest_session,
209
+ "latest_session_time": latest_session_time
210
+ }
211
+
212
+ except Exception as e:
213
+ logger.error(f"Error getting session information: {e}", exc_info=True)
214
+ return {
215
+ "claude_sessions": [],
216
+ "codex_sessions": [],
217
+ "latest_session": None,
218
+ "latest_session_time": None
219
+ }
220
+
221
+
222
+ def get_configuration_summary() -> Dict:
223
+ """
224
+ Extract key configuration values.
225
+
226
+ Returns:
227
+ dict: Configuration summary
228
+ """
229
+ try:
230
+ config = ReAlignConfig.load()
231
+ return {
232
+ "llm_provider": config.llm_provider,
233
+ "use_llm": config.use_LLM,
234
+ "redact_on_match": config.redact_on_match,
235
+ "hooks_installation": config.hooks_installation,
236
+ "auto_detect_claude": config.auto_detect_claude,
237
+ "auto_detect_codex": config.auto_detect_codex
238
+ }
239
+ except Exception as e:
240
+ logger.error(f"Error getting configuration: {e}", exc_info=True)
241
+ return {
242
+ "llm_provider": "unknown",
243
+ "use_llm": False,
244
+ "redact_on_match": False,
245
+ "hooks_installation": "unknown",
246
+ "auto_detect_claude": False,
247
+ "auto_detect_codex": False
248
+ }
249
+
250
+
251
+ def get_recent_activity() -> Dict:
252
+ """
253
+ Get recent commit and session statistics.
254
+
255
+ Returns:
256
+ dict: Recent activity information
257
+ """
258
+ try:
259
+ realign_path = Path.cwd() / '.realign'
260
+
261
+ # Latest commit from .realign/.git
262
+ latest_commit_hash = None
263
+ latest_commit_message = None
264
+ latest_commit_time = None
265
+
266
+ git_path = realign_path / '.git'
267
+ if git_path.exists():
268
+ try:
269
+ result = subprocess.run(
270
+ ['git', '-C', str(git_path), 'log', '-1', '--format=%h|%s|%ct'],
271
+ capture_output=True,
272
+ text=True,
273
+ timeout=2
274
+ )
275
+ if result.returncode == 0 and result.stdout.strip():
276
+ parts = result.stdout.strip().split('|')
277
+ if len(parts) >= 3:
278
+ latest_commit_hash = parts[0]
279
+ latest_commit_message = parts[1]
280
+ latest_commit_time = datetime.fromtimestamp(int(parts[2]))
281
+ except (subprocess.TimeoutExpired, ValueError, Exception) as e:
282
+ logger.warning(f"Failed to get latest commit: {e}")
283
+
284
+ # Unpushed commits count
285
+ unpushed_count = 0
286
+ try:
287
+ unpushed_commits = get_unpushed_commits()
288
+ unpushed_count = len(unpushed_commits)
289
+ except Exception as e:
290
+ logger.warning(f"Failed to get unpushed commits: {e}")
291
+
292
+ # Total sessions tracked
293
+ total_sessions = 0
294
+ sessions_path = realign_path / 'sessions'
295
+ if sessions_path.exists():
296
+ try:
297
+ total_sessions = len(list(sessions_path.glob('*.jsonl')))
298
+ except Exception as e:
299
+ logger.warning(f"Failed to count sessions: {e}")
300
+
301
+ return {
302
+ "latest_commit_hash": latest_commit_hash,
303
+ "latest_commit_message": latest_commit_message,
304
+ "latest_commit_time": latest_commit_time,
305
+ "unpushed_count": unpushed_count,
306
+ "total_sessions": total_sessions
307
+ }
308
+
309
+ except Exception as e:
310
+ logger.error(f"Error getting recent activity: {e}", exc_info=True)
311
+ return {
312
+ "latest_commit_hash": None,
313
+ "latest_commit_message": None,
314
+ "latest_commit_time": None,
315
+ "unpushed_count": 0,
316
+ "total_sessions": 0
317
+ }
318
+
319
+
320
+ def collect_all_status_data() -> Dict:
321
+ """
322
+ Collect all status information from all data functions.
323
+
324
+ Returns:
325
+ dict: Complete status data
326
+ """
327
+ return {
328
+ "init": check_initialization_status(),
329
+ "watcher": detect_watcher_status(),
330
+ "sessions": get_session_information(),
331
+ "config": get_configuration_summary(),
332
+ "activity": get_recent_activity()
333
+ }
334
+
335
+
336
+ # ============================================================================
337
+ # Helper Functions
338
+ # ============================================================================
339
+
340
+ def format_time_with_relative(dt: datetime) -> str:
341
+ """
342
+ Format datetime with both absolute and relative time.
343
+
344
+ Args:
345
+ dt: Datetime to format
346
+
347
+ Returns:
348
+ str: Formatted time string (e.g., "2025-11-29 14:23:45 (2 mins ago)")
349
+ """
350
+ absolute = dt.strftime('%Y-%m-%d %H:%M:%S')
351
+
352
+ # Calculate relative time
353
+ now = datetime.now()
354
+ diff = now - dt
355
+ total_seconds = diff.total_seconds()
356
+
357
+ if total_seconds < 60:
358
+ relative = "just now"
359
+ elif total_seconds < 3600:
360
+ mins = int(total_seconds // 60)
361
+ relative = f"{mins} min{'s' if mins != 1 else ''} ago"
362
+ elif diff.days == 0:
363
+ hours = int(total_seconds // 3600)
364
+ relative = f"{hours} hour{'s' if hours != 1 else ''} ago"
365
+ elif diff.days < 7:
366
+ relative = f"{diff.days} day{'s' if diff.days != 1 else ''} ago"
367
+ else:
368
+ weeks = diff.days // 7
369
+ relative = f"{weeks} week{'s' if weeks != 1 else ''} ago"
370
+
371
+ return f"{absolute} ({relative})"
372
+
373
+
374
+ def abbreviate_path(path: Path) -> str:
375
+ """
376
+ Abbreviate home directory with ~.
377
+
378
+ Args:
379
+ path: Path to abbreviate
380
+
381
+ Returns:
382
+ str: Abbreviated path
383
+ """
384
+ home = Path.home()
385
+ try:
386
+ relative = path.relative_to(home)
387
+ return f"~/{relative}"
388
+ except ValueError:
389
+ # Path is not under home directory
390
+ return str(path)
391
+
392
+
393
+ # ============================================================================
394
+ # Display Function
395
+ # ============================================================================
396
+
397
+ def display_status(data: Dict, is_watch: bool = False) -> None:
398
+ """
399
+ Display status using Rich library.
400
+
401
+ Args:
402
+ data: Status data from collect_all_status_data()
403
+ is_watch: Whether in watch mode
404
+ """
405
+ # Title panel
406
+ console.print(Panel("ReAlign System Status", style="bold cyan"))
407
+
408
+ # Section 1: Initialization
409
+ console.print("\n[bold cyan][1] Initialization[/bold cyan]")
410
+ if data['init']['initialized']:
411
+ console.print(f" Status: Initialized [green]✓[/green]")
412
+ console.print(f" Path: {data['init']['project_path']}")
413
+ console.print(f" Shadow Repository: .realign/.git ({data['init']['total_commits']} commits)")
414
+ else:
415
+ console.print(f" Status: Not initialized [red]✗[/red]")
416
+ console.print(f"\n [dim]ReAlign is not initialized in this directory.[/dim]")
417
+ console.print(f" [dim]Run 'aline init' to set up session tracking.[/dim]")
418
+ return # Stop here, don't show other sections
419
+
420
+ # Section 2: Watcher
421
+ console.print("\n[bold cyan][2] Watcher[/bold cyan]")
422
+ status = data['watcher']['status']
423
+ if status == "Running":
424
+ console.print(f" Status: Running [green]✓[/green]")
425
+ elif status == "Stopped":
426
+ console.print(f" Status: Stopped [red]✗[/red]")
427
+ else:
428
+ console.print(f" Status: Inactive [yellow]✗[/yellow]")
429
+ console.print(f" [dim]Suggestion: Restart MCP server[/dim]")
430
+
431
+ # Auto-commit status
432
+ if data['watcher']['auto_commit_enabled']:
433
+ console.print(f" Auto-commit: [green]Enabled[/green]")
434
+ else:
435
+ console.print(f" Auto-commit: [red]Disabled[/red]")
436
+
437
+ # Timing info
438
+ console.print(f" Timing: Debounce {data['watcher']['debounce_delay']}s | Cooldown {data['watcher']['cooldown']}s")
439
+
440
+ # Last activity (if available)
441
+ if data['watcher']['last_activity']:
442
+ time_str = format_time_with_relative(data['watcher']['last_activity'])
443
+ console.print(f" Last Activity: {time_str}")
444
+
445
+ # Section 3: Sessions
446
+ console.print("\n[bold cyan][3] Active Sessions[/bold cyan]")
447
+ claude_count = len(data['sessions']['claude_sessions'])
448
+ codex_count = len(data['sessions']['codex_sessions'])
449
+
450
+ if claude_count > 0:
451
+ console.print(f" Claude Code: {claude_count} session(s)")
452
+ for session in data['sessions']['claude_sessions'][:3]: # Show max 3
453
+ abbreviated_path = abbreviate_path(session)
454
+ console.print(f" {abbreviated_path}")
455
+ if claude_count > 3:
456
+ console.print(f" [dim]... and {claude_count - 3} more[/dim]")
457
+ else:
458
+ console.print(f" Claude Code: [dim]No sessions found[/dim]")
459
+
460
+ if codex_count > 0:
461
+ console.print(f" Codex: {codex_count} session(s)")
462
+ for session in data['sessions']['codex_sessions'][:3]:
463
+ abbreviated_path = abbreviate_path(session)
464
+ console.print(f" {abbreviated_path}")
465
+ if codex_count > 3:
466
+ console.print(f" [dim]... and {codex_count - 3} more[/dim]")
467
+ else:
468
+ console.print(f" Codex: [dim]No sessions found[/dim]")
469
+
470
+ # Section 4: Configuration
471
+ console.print("\n[bold cyan][4] Configuration[/bold cyan]")
472
+
473
+ # LLM Provider
474
+ llm_status = "[green]Enabled[/green]" if data['config']['use_llm'] else "[red]Disabled[/red]"
475
+ console.print(f" LLM Provider: {data['config']['llm_provider']} ({llm_status})")
476
+
477
+ # Redaction
478
+ redaction_status = "[green]Enabled[/green]" if data['config']['redact_on_match'] else "[red]Disabled[/red]"
479
+ console.print(f" Redaction: {redaction_status}")
480
+
481
+ # Hook Mode
482
+ console.print(f" Hook Mode: {data['config']['hooks_installation']}")
483
+
484
+ # Section 5: Recent Activity
485
+ console.print("\n[bold cyan][5] Recent Activity[/bold cyan]")
486
+
487
+ # Latest commit
488
+ if data['activity']['latest_commit_hash']:
489
+ commit_msg = data['activity']['latest_commit_message']
490
+ # Extract turn number if present
491
+ if 'Turn' in commit_msg:
492
+ # Format: "Session xxx, Turn N: message"
493
+ parts = commit_msg.split(': ', 1)
494
+ if len(parts) == 2:
495
+ commit_msg = parts[1][:50] # Truncate message
496
+ else:
497
+ commit_msg = commit_msg[:50]
498
+
499
+ time_str = format_time_with_relative(data['activity']['latest_commit_time'])
500
+ console.print(f" Latest Commit: {data['activity']['latest_commit_hash']} - {commit_msg}")
501
+ console.print(f" {time_str}")
502
+ else:
503
+ console.print(f" Latest Commit: [dim]No commits yet[/dim]")
504
+
505
+ # Unpushed commits
506
+ unpushed = data['activity']['unpushed_count']
507
+ if unpushed > 0:
508
+ console.print(f" Unpushed: {unpushed} commit{'s' if unpushed != 1 else ''}")
509
+ else:
510
+ console.print(f" Unpushed: [dim]None[/dim]")
511
+
512
+ # Sessions tracked
513
+ total_sessions = data['activity']['total_sessions']
514
+ console.print(f" Sessions Tracked: {total_sessions}")
515
+
516
+
517
+ # ============================================================================
518
+ # Main Command Function
519
+ # ============================================================================
520
+
521
+ def status_command(watch: bool = False) -> int:
522
+ """
523
+ Main entry point for status command.
524
+
525
+ Args:
526
+ watch: Enable watch mode (continuous refresh)
527
+
528
+ Returns:
529
+ int: Exit code (0 = success, 1 = error)
530
+ """
531
+ try:
532
+ if watch:
533
+ # Watch mode: continuous refresh
534
+ while True:
535
+ console.clear()
536
+ console.print(f"[dim]Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}[/dim]\n")
537
+
538
+ # Collect all data
539
+ data = collect_all_status_data()
540
+
541
+ # Display
542
+ display_status(data, is_watch=True)
543
+
544
+ # Wait 2 seconds
545
+ time.sleep(2)
546
+ else:
547
+ # Single display
548
+ data = collect_all_status_data()
549
+ display_status(data, is_watch=False)
550
+
551
+ return 0
552
+
553
+ except KeyboardInterrupt:
554
+ console.print("\n[yellow]Status monitoring stopped[/yellow]")
555
+ return 0
556
+ except Exception as e:
557
+ logger.error(f"Error in status command: {e}", exc_info=True)
558
+ console.print(f"[red]Error: {e}[/red]")
559
+ return 1
@@ -0,0 +1,91 @@
1
+ """Sync command - Bidirectional synchronization (pull + push)."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..tracker.git_tracker import ReAlignGitTracker
8
+ from ..logging_config import setup_logger
9
+
10
+ logger = setup_logger('realign.commands.sync', 'sync.log')
11
+
12
+
13
+ def sync_command(repo_root: Optional[Path] = None) -> int:
14
+ """
15
+ Synchronize with remote repository (pull then push).
16
+
17
+ This command performs a bidirectional sync:
18
+ 1. Pulls updates from remote
19
+ 2. Pushes local commits to remote
20
+
21
+ Args:
22
+ repo_root: Path to repository root (uses cwd if not provided)
23
+
24
+ Returns:
25
+ Exit code (0 for success, 1 for error)
26
+ """
27
+ # Get project root
28
+ if repo_root is None:
29
+ repo_root = Path(os.getcwd()).resolve()
30
+
31
+ # Initialize tracker
32
+ tracker = ReAlignGitTracker(repo_root)
33
+
34
+ # Check if repository is initialized
35
+ if not tracker.is_initialized():
36
+ print("❌ Repository not initialized")
37
+ print("Run 'aline init' first")
38
+ return 1
39
+
40
+ # Check if remote is configured
41
+ if not tracker.has_remote():
42
+ print("❌ No remote configured")
43
+ print("\nTo join a shared repository:")
44
+ print(" aline init --join <repo>")
45
+ print("\nOr to set up sharing:")
46
+ print(" aline init --share")
47
+ return 1
48
+
49
+ remote_url = tracker.get_remote_url()
50
+ print(f"Synchronizing with: {remote_url}")
51
+ print()
52
+
53
+ # Step 1: Pull from remote
54
+ print("[1/2] Pulling from remote...")
55
+
56
+ pull_success = tracker.safe_pull()
57
+
58
+ if not pull_success:
59
+ print("❌ Pull failed")
60
+ print("\nSync aborted. Fix pull issues before syncing.")
61
+ print("Check logs: .realign/logs/sync.log")
62
+ return 1
63
+
64
+ print("✓ Pull completed")
65
+ print()
66
+
67
+ # Step 2: Push to remote
68
+ print("[2/2] Pushing to remote...")
69
+
70
+ # Get unpushed commits
71
+ unpushed = tracker.get_unpushed_commits()
72
+
73
+ if not unpushed:
74
+ print("✓ No commits to push")
75
+ print("\n✓ Synchronization complete")
76
+ return 0
77
+
78
+ print(f"Found {len(unpushed)} unpushed commit(s)")
79
+
80
+ push_success = tracker.safe_push()
81
+
82
+ if push_success:
83
+ print(f"✓ Pushed {len(unpushed)} commit(s)")
84
+ print("\n✓ Synchronization complete")
85
+ return 0
86
+ else:
87
+ print("❌ Push failed")
88
+ print("\nPull succeeded, but push failed.")
89
+ print("You can try 'aline push' separately")
90
+ print("Check logs: .realign/logs/sync.log")
91
+ return 1