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,1123 @@
1
+ """ReAlignGitTracker - Independent Git repository for AI work history tracking.
2
+
3
+ This module implements the core Git tracking layer of Plan A, which maintains
4
+ an independent Git repository in .realign/.git that mirrors project file structure.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Optional, List, Dict, Any
13
+ import yaml
14
+
15
+ from ..logging_config import setup_logger
16
+
17
+ logger = setup_logger('realign.tracker', 'tracker.log')
18
+
19
+
20
+ class ReAlignGitTracker:
21
+ """
22
+ Manages an independent Git repository in ~/.aline/{project_name}/ for tracking AI work history.
23
+
24
+ Key features:
25
+ - Independent git repository (separate from user's .git)
26
+ - Mirrors project file structure for path consistency
27
+ - Generates semantic commit messages
28
+ - Supports remote synchronization
29
+ """
30
+
31
+ def __init__(self, project_root: Path):
32
+ """
33
+ Initialize the Git tracker.
34
+
35
+ Args:
36
+ project_root: Root directory of the user's project
37
+ """
38
+ self.project_root = Path(project_root).resolve()
39
+ self.realign_dir = self._get_realign_dir()
40
+ self.realign_git = self.realign_dir / ".git"
41
+
42
+ # Load configuration
43
+ self.config = self._load_config()
44
+
45
+ def _get_realign_dir(self) -> Path:
46
+ """
47
+ Get the ReAlign directory path for this project.
48
+
49
+ First checks for .realign-config file in project root,
50
+ otherwise uses default location ~/.aline/{project_name}/
51
+
52
+ Returns:
53
+ Path to the ReAlign directory
54
+ """
55
+ config_marker = self.project_root / ".realign-config"
56
+
57
+ if config_marker.exists():
58
+ # Read the configured path
59
+ configured_path = config_marker.read_text(encoding="utf-8").strip()
60
+ return Path(configured_path)
61
+ else:
62
+ # Use default location
63
+ project_name = self.project_root.name
64
+ return Path.home() / ".aline" / project_name
65
+
66
+ def get_remote_url(self) -> Optional[str]:
67
+ """
68
+ Get the configured remote URL.
69
+
70
+ Returns:
71
+ Remote URL if configured, None otherwise
72
+ """
73
+ try:
74
+ result = self._run_git(
75
+ ["remote", "get-url", "origin"],
76
+ cwd=self.realign_dir,
77
+ capture_output=True,
78
+ text=True,
79
+ check=False
80
+ )
81
+
82
+ if result.returncode == 0:
83
+ return result.stdout.strip()
84
+ return None
85
+
86
+ except Exception as e:
87
+ logger.error(f"Failed to get remote URL: {e}")
88
+ return None
89
+
90
+ def has_remote(self) -> bool:
91
+ """
92
+ Check if a remote is configured.
93
+
94
+ Returns:
95
+ True if remote exists, False otherwise
96
+ """
97
+ return self.get_remote_url() is not None
98
+
99
+ def get_current_branch(self) -> Optional[str]:
100
+ """
101
+ Get the current branch name.
102
+
103
+ Returns:
104
+ Current branch name if available, None otherwise
105
+ """
106
+ try:
107
+ result = self._run_git(
108
+ ["rev-parse", "--abbrev-ref", "HEAD"],
109
+ cwd=self.realign_dir,
110
+ capture_output=True,
111
+ text=True,
112
+ check=False
113
+ )
114
+
115
+ if result.returncode == 0:
116
+ branch = result.stdout.strip()
117
+ # Return None if in detached HEAD state
118
+ return branch if branch != "HEAD" else None
119
+ return None
120
+
121
+ except Exception as e:
122
+ logger.error(f"Failed to get current branch: {e}")
123
+ return None
124
+
125
+ def get_member_branch(self) -> Optional[str]:
126
+ """
127
+ Get the member branch name from config if available.
128
+
129
+ For joined repositories, members work on their own branch
130
+ (e.g., "username/master") instead of the owner's master branch.
131
+
132
+ Returns:
133
+ Member branch name from config, or None if not configured
134
+ """
135
+ sharing_config = self.config.get('sharing', {})
136
+ return sharing_config.get('member_branch')
137
+
138
+ def _load_config(self) -> Dict[str, Any]:
139
+ """Load .realign/config.yaml configuration."""
140
+ config_path = self.realign_dir / "config.yaml"
141
+
142
+ if not config_path.exists():
143
+ return {}
144
+
145
+ try:
146
+ with open(config_path, 'r', encoding='utf-8') as f:
147
+ return yaml.safe_load(f) or {}
148
+ except Exception as e:
149
+ logger.error(f"Failed to load config: {e}")
150
+ return {}
151
+
152
+ def is_initialized(self) -> bool:
153
+ """Check if the .realign/.git repository is initialized."""
154
+ return self.realign_git.exists() and (self.realign_git / "config").exists()
155
+
156
+ def init_repo(self) -> bool:
157
+ """
158
+ Initialize the independent .realign/.git repository.
159
+
160
+ Returns:
161
+ True if successful, False otherwise
162
+ """
163
+ if self.is_initialized():
164
+ logger.info("Git mirror already initialized")
165
+ return True
166
+
167
+ try:
168
+ # Create .realign directory
169
+ self.realign_dir.mkdir(parents=True, exist_ok=True)
170
+
171
+ # Initialize Git repository
172
+ self._run_git(["init"], cwd=self.realign_dir, check=True)
173
+ logger.info(f"Initialized Git repository at {self.realign_git}")
174
+
175
+ # Create .gitignore to exclude certain files
176
+ gitignore_path = self.realign_dir / ".gitignore"
177
+ gitignore_content = (
178
+ "# Note: sessions/ is now tracked for sharing functionality\n"
179
+ "# Session files are committed to enable team collaboration\n\n"
180
+ "# Exclude metadata (internal use)\n"
181
+ ".metadata/\n\n"
182
+ "# Exclude original sessions (may contain secrets)\n"
183
+ "sessions-original/\n\n"
184
+ "# Exclude lock files\n"
185
+ ".commit.lock\n"
186
+ ".hash_registry.lock\n\n"
187
+ "# Exclude temporary files\n"
188
+ "*.tmp\n"
189
+ "*.corrupted.*\n"
190
+ )
191
+ gitignore_path.write_text(gitignore_content, encoding='utf-8')
192
+
193
+ # Initial commit
194
+ self._run_git(["add", ".gitignore"], cwd=self.realign_dir, check=True)
195
+ self._run_git(
196
+ ["commit", "-m", "Initial commit: ReAlign Git mirror"],
197
+ cwd=self.realign_dir,
198
+ check=True
199
+ )
200
+
201
+ logger.info("✓ Git mirror initialized successfully")
202
+ return True
203
+
204
+ except Exception as e:
205
+ logger.error(f"Failed to initialize Git mirror: {e}", exc_info=True)
206
+ return False
207
+
208
+ def get_mirror_path(self, file_path: Path) -> Path:
209
+ """
210
+ Get the mirror path for a given file.
211
+
212
+ Args:
213
+ file_path: Absolute path to the file in the project
214
+
215
+ Returns:
216
+ Path to the mirrored file in .realign/mirror/
217
+ """
218
+ # Convert to absolute path
219
+ file_path = Path(file_path).resolve()
220
+
221
+ # Get relative path from project root
222
+ try:
223
+ rel_path = file_path.relative_to(self.project_root)
224
+ except ValueError:
225
+ # File is outside project root - skip
226
+ logger.warning(f"File {file_path} is outside project root")
227
+ return None
228
+
229
+ # Return mirror path in mirror/ subdirectory
230
+ return self.realign_dir / "mirror" / rel_path
231
+
232
+ def _compute_file_hash(self, file_path: Path) -> Optional[str]:
233
+ """Compute SHA256 hash of a file."""
234
+ if not file_path.exists():
235
+ return None
236
+
237
+ try:
238
+ sha256_hash = hashlib.sha256()
239
+ with open(file_path, "rb") as f:
240
+ for byte_block in iter(lambda: f.read(4096), b""):
241
+ sha256_hash.update(byte_block)
242
+ return sha256_hash.hexdigest()
243
+ except Exception as e:
244
+ logger.error(f"Failed to compute hash for {file_path}: {e}")
245
+ return None
246
+
247
+ def _should_copy_file(self, source_path: Path, mirror_path: Path) -> bool:
248
+ """
249
+ Determine if a file should be copied (hash-based optimization).
250
+
251
+ Args:
252
+ source_path: Source file path
253
+ mirror_path: Mirror file path
254
+
255
+ Returns:
256
+ True if file should be copied, False if unchanged
257
+ """
258
+ # If mirror doesn't exist, must copy
259
+ if not mirror_path.exists():
260
+ return True
261
+
262
+ # Compare file hashes
263
+ source_hash = self._compute_file_hash(source_path)
264
+ mirror_hash = self._compute_file_hash(mirror_path)
265
+
266
+ if source_hash is None or mirror_hash is None:
267
+ # If hash computation failed, copy to be safe
268
+ return True
269
+
270
+ # Only copy if hashes differ
271
+ return source_hash != mirror_hash
272
+
273
+ def mirror_file(self, file_path: Path) -> bool:
274
+ """
275
+ Mirror a single file to .realign/ directory.
276
+
277
+ Args:
278
+ file_path: Absolute path to the file to mirror
279
+
280
+ Returns:
281
+ True if file was copied, False if skipped (unchanged) or error
282
+ """
283
+ try:
284
+ file_path = Path(file_path).resolve()
285
+
286
+ # Check if file exists
287
+ if not file_path.exists():
288
+ logger.warning(f"File does not exist: {file_path}")
289
+ return False
290
+
291
+ # Get mirror path
292
+ mirror_path = self.get_mirror_path(file_path)
293
+ if mirror_path is None:
294
+ return False
295
+
296
+ # Check if copy is needed (hash optimization)
297
+ if not self._should_copy_file(file_path, mirror_path):
298
+ logger.debug(f"File unchanged, skipping: {file_path.name}")
299
+ return False
300
+
301
+ # Create parent directory
302
+ mirror_path.parent.mkdir(parents=True, exist_ok=True)
303
+
304
+ # Copy file
305
+ shutil.copy2(file_path, mirror_path)
306
+ logger.debug(f"Mirrored: {file_path} -> {mirror_path}")
307
+ return True
308
+
309
+ except Exception as e:
310
+ logger.error(f"Failed to mirror file {file_path}: {e}", exc_info=True)
311
+ return False
312
+
313
+ def mirror_files(self, file_paths: List[Path]) -> List[Path]:
314
+ """
315
+ Mirror multiple files to .realign/ directory.
316
+
317
+ Args:
318
+ file_paths: List of absolute paths to files
319
+
320
+ Returns:
321
+ List of files that were actually copied (changed files only)
322
+ """
323
+ copied_files = []
324
+
325
+ for file_path in file_paths:
326
+ if self.mirror_file(file_path):
327
+ mirror_path = self.get_mirror_path(file_path)
328
+ if mirror_path:
329
+ copied_files.append(mirror_path)
330
+
331
+ logger.info(f"Mirrored {len(copied_files)} of {len(file_paths)} files")
332
+ return copied_files
333
+
334
+ def has_changes(self) -> bool:
335
+ """
336
+ Check if there are any uncommitted changes in the .realign/ repository.
337
+
338
+ Returns:
339
+ True if there are changes, False otherwise
340
+ """
341
+ if not self.is_initialized():
342
+ return False
343
+
344
+ try:
345
+ # Check for staged and unstaged changes
346
+ result = self._run_git(
347
+ ["status", "--porcelain"],
348
+ cwd=self.realign_dir,
349
+ check=True,
350
+ capture_output=True,
351
+ text=True
352
+ )
353
+
354
+ return bool(result.stdout.strip())
355
+
356
+ except Exception as e:
357
+ logger.error(f"Failed to check for changes: {e}")
358
+ return False
359
+
360
+ def _generate_commit_message(
361
+ self,
362
+ session_id: str,
363
+ turn_number: int,
364
+ user_message: str,
365
+ llm_title: str,
366
+ llm_description: str,
367
+ model_name: str
368
+ ) -> str:
369
+ """
370
+ Generate a semantic commit message using LLM-generated content.
371
+
372
+ Format:
373
+ {llm_title}
374
+
375
+ {llm_description}
376
+
377
+ ---
378
+ Session: {session_id} | Turn: #{turn_number} | Model: {model_name}
379
+ Request: {user_message}
380
+ """
381
+ # Validate title before using it
382
+ if not llm_title or len(llm_title.strip()) < 2:
383
+ raise ValueError(f"Invalid commit title: '{llm_title}' - too short or empty")
384
+
385
+ if llm_title.strip() in ["{", "}", "[", "]"]:
386
+ raise ValueError(f"Invalid commit title: '{llm_title}' - appears to be truncated JSON bracket")
387
+
388
+ # Construct commit message with LLM-generated content
389
+ message = f"""{llm_title}
390
+
391
+ {llm_description}
392
+
393
+ ---
394
+ Session: {session_id} | Turn: #{turn_number} | Model: {model_name}
395
+ Request: {user_message}"""
396
+
397
+ return message
398
+
399
+ def commit_turn(
400
+ self,
401
+ session_id: str,
402
+ turn_number: int,
403
+ user_message: str,
404
+ llm_title: str,
405
+ llm_description: str,
406
+ model_name: str,
407
+ modified_files: List[Path],
408
+ session_file: Optional[Path] = None
409
+ ) -> Optional[str]:
410
+ """
411
+ Commit a completed dialogue turn to the .realign/.git repository.
412
+
413
+ Args:
414
+ session_id: Session identifier (e.g., "minhao_claude_abc123")
415
+ turn_number: Turn number within the session
416
+ user_message: User's message/request
417
+ llm_title: LLM-generated one-line summary (imperative mood)
418
+ llm_description: LLM-generated detailed description
419
+ model_name: Name of the model that generated the summary
420
+ modified_files: List of files modified in this turn
421
+ session_file: Optional path to the session file to copy to sessions/
422
+
423
+ Returns:
424
+ Commit hash if successful, None if no changes or error
425
+ """
426
+ if not self.is_initialized():
427
+ logger.warning("Git mirror not initialized")
428
+ if not self.init_repo():
429
+ return None
430
+
431
+ try:
432
+ # Copy session file to sessions/ directory if provided
433
+ if session_file and session_file.exists():
434
+ sessions_dir = self.realign_dir / "sessions"
435
+ sessions_dir.mkdir(parents=True, exist_ok=True)
436
+ session_dest = sessions_dir / session_file.name
437
+
438
+ # Only copy if file doesn't exist or has changed
439
+ if not session_dest.exists() or session_file.read_bytes() != session_dest.read_bytes():
440
+ shutil.copy2(session_file, session_dest)
441
+ logger.debug(f"Copied session file to {session_dest}")
442
+
443
+ # Mirror modified files
444
+ mirrored_files = self.mirror_files(modified_files)
445
+
446
+ # Check if there are any changes
447
+ if not mirrored_files and not self.has_changes():
448
+ logger.info(f"No changes to commit for turn {turn_number}")
449
+ return None
450
+
451
+ # Stage all changes in .realign/
452
+ self._run_git(["add", "-A"], cwd=self.realign_dir, check=True)
453
+
454
+ # Check again after staging
455
+ if not self.has_changes():
456
+ logger.info("No changes after staging")
457
+ return None
458
+
459
+ # Generate commit message
460
+ try:
461
+ commit_message = self._generate_commit_message(
462
+ session_id,
463
+ turn_number,
464
+ user_message,
465
+ llm_title,
466
+ llm_description,
467
+ model_name
468
+ )
469
+ except ValueError as e:
470
+ logger.error(f"Invalid commit message generated: {e}")
471
+ logger.debug(f"Title: '{llm_title}'")
472
+ return None
473
+
474
+ # Commit
475
+ self._run_git(
476
+ ["commit", "-m", commit_message],
477
+ cwd=self.realign_dir,
478
+ check=True
479
+ )
480
+
481
+ # Get commit hash
482
+ result = self._run_git(
483
+ ["rev-parse", "HEAD"],
484
+ cwd=self.realign_dir,
485
+ check=True,
486
+ capture_output=True,
487
+ text=True
488
+ )
489
+ commit_hash = result.stdout.strip()
490
+
491
+ logger.info(f"✓ Committed turn {turn_number}: {commit_hash[:8]}")
492
+
493
+ return commit_hash
494
+
495
+ except Exception as e:
496
+ logger.error(f"Failed to commit turn {turn_number}: {e}", exc_info=True)
497
+ return None
498
+
499
+ def setup_remote(self, remote_url: str) -> bool:
500
+ """
501
+ Configure remote repository for sharing.
502
+
503
+ Args:
504
+ remote_url: Git remote URL (e.g., https://github.com/user/repo.git)
505
+
506
+ Returns:
507
+ True if successful, False otherwise
508
+ """
509
+ if not self.is_initialized():
510
+ logger.error("Repository not initialized")
511
+ return False
512
+
513
+ try:
514
+ # Check if remote already exists
515
+ existing_remote = self.get_remote_url()
516
+
517
+ if existing_remote:
518
+ if existing_remote == remote_url:
519
+ logger.info("Remote already configured with same URL")
520
+ return True
521
+ else:
522
+ # Update existing remote
523
+ self._run_git(
524
+ ["remote", "set-url", "origin", remote_url],
525
+ cwd=self.realign_dir,
526
+ check=True
527
+ )
528
+ logger.info(f"Updated remote URL: {remote_url}")
529
+ else:
530
+ # Add new remote
531
+ self._run_git(
532
+ ["remote", "add", "origin", remote_url],
533
+ cwd=self.realign_dir,
534
+ check=True
535
+ )
536
+ logger.info(f"Added remote: {remote_url}")
537
+
538
+ return True
539
+
540
+ except Exception as e:
541
+ logger.error(f"Failed to setup remote: {e}", exc_info=True)
542
+ return False
543
+
544
+ def create_branch(self, branch_name: str, start_point: str = "master") -> bool:
545
+ """
546
+ Create a new branch starting from a given point.
547
+
548
+ Args:
549
+ branch_name: Name of the branch to create (e.g., "username/master")
550
+ start_point: Starting point for the branch (default: "master")
551
+
552
+ Returns:
553
+ True if successful, False otherwise
554
+ """
555
+ try:
556
+ # Create and checkout the branch
557
+ result = self._run_git(
558
+ ["checkout", "-b", branch_name, start_point],
559
+ cwd=self.realign_dir,
560
+ check=False,
561
+ capture_output=True,
562
+ text=True
563
+ )
564
+
565
+ if result.returncode == 0:
566
+ logger.info(f"Created and checked out branch: {branch_name}")
567
+ return True
568
+ else:
569
+ logger.error(f"Failed to create branch {branch_name}: {result.stderr}")
570
+ return False
571
+
572
+ except Exception as e:
573
+ logger.error(f"Failed to create branch: {e}", exc_info=True)
574
+ return False
575
+
576
+ def checkout_branch(self, branch_name: str) -> bool:
577
+ """
578
+ Checkout an existing branch.
579
+
580
+ Args:
581
+ branch_name: Name of the branch to checkout
582
+
583
+ Returns:
584
+ True if successful, False otherwise
585
+ """
586
+ try:
587
+ result = self._run_git(
588
+ ["checkout", branch_name],
589
+ cwd=self.realign_dir,
590
+ check=False,
591
+ capture_output=True,
592
+ text=True
593
+ )
594
+
595
+ if result.returncode == 0:
596
+ logger.info(f"Checked out branch: {branch_name}")
597
+ return True
598
+ else:
599
+ logger.error(f"Failed to checkout branch {branch_name}: {result.stderr}")
600
+ return False
601
+
602
+ except Exception as e:
603
+ logger.error(f"Failed to checkout branch: {e}", exc_info=True)
604
+ return False
605
+
606
+ def verify_commit_exists(self, commit_hash: str) -> bool:
607
+ """
608
+ Verify that a commit exists and is reachable in the repository.
609
+
610
+ Args:
611
+ commit_hash: The commit hash to verify
612
+
613
+ Returns:
614
+ True if commit exists, False otherwise
615
+ """
616
+ try:
617
+ result = self._run_git(
618
+ ["rev-parse", "--verify", commit_hash],
619
+ cwd=self.realign_dir,
620
+ check=False,
621
+ capture_output=True,
622
+ text=True
623
+ )
624
+ return result.returncode == 0
625
+ except Exception as e:
626
+ logger.error(f"Failed to verify commit {commit_hash}: {e}", exc_info=True)
627
+ return False
628
+
629
+ def get_commit_info(self, commit_hash: str) -> Optional[Dict[str, str]]:
630
+ """
631
+ Get information about a specific commit.
632
+
633
+ Args:
634
+ commit_hash: The commit hash to query
635
+
636
+ Returns:
637
+ Dictionary with keys: 'hash', 'timestamp', 'message', or None if commit doesn't exist
638
+ """
639
+ try:
640
+ result = self._run_git(
641
+ ["show", "--format=%H|%at|%s", "--no-patch", commit_hash],
642
+ cwd=self.realign_dir,
643
+ check=False,
644
+ capture_output=True,
645
+ text=True
646
+ )
647
+
648
+ if result.returncode != 0:
649
+ logger.error(f"Failed to get commit info for {commit_hash}: {result.stderr}")
650
+ return None
651
+
652
+ # Parse output: hash|timestamp|subject
653
+ parts = result.stdout.strip().split('|', 2)
654
+ if len(parts) < 3:
655
+ return None
656
+
657
+ return {
658
+ 'hash': parts[0],
659
+ 'timestamp': parts[1],
660
+ 'message': parts[2]
661
+ }
662
+
663
+ except Exception as e:
664
+ logger.error(f"Failed to get commit info: {e}", exc_info=True)
665
+ return None
666
+
667
+ def reset_to_commit(self, commit_hash: str, hard: bool = True) -> bool:
668
+ """
669
+ Reset the repository to a specific commit.
670
+
671
+ Args:
672
+ commit_hash: The commit hash to reset to
673
+ hard: If True, performs hard reset (discards changes). Default: True
674
+
675
+ Returns:
676
+ True if reset successful, False otherwise
677
+ """
678
+ try:
679
+ # Verify commit exists first
680
+ if not self.verify_commit_exists(commit_hash):
681
+ logger.error(f"Commit {commit_hash} does not exist")
682
+ return False
683
+
684
+ # Perform reset
685
+ reset_type = "--hard" if hard else "--soft"
686
+ result = self._run_git(
687
+ ["reset", reset_type, commit_hash],
688
+ cwd=self.realign_dir,
689
+ check=False,
690
+ capture_output=True,
691
+ text=True
692
+ )
693
+
694
+ if result.returncode != 0:
695
+ logger.error(f"Failed to reset to {commit_hash}: {result.stderr}")
696
+ return False
697
+
698
+ # Verify reset succeeded by checking HEAD
699
+ head_result = self._run_git(
700
+ ["rev-parse", "HEAD"],
701
+ cwd=self.realign_dir,
702
+ check=False,
703
+ capture_output=True,
704
+ text=True
705
+ )
706
+
707
+ if head_result.returncode == 0:
708
+ current_head = head_result.stdout.strip()
709
+ # Get full hash of target commit
710
+ target_result = self._run_git(
711
+ ["rev-parse", commit_hash],
712
+ cwd=self.realign_dir,
713
+ check=False,
714
+ capture_output=True,
715
+ text=True
716
+ )
717
+
718
+ if target_result.returncode == 0:
719
+ target_full_hash = target_result.stdout.strip()
720
+ if current_head == target_full_hash:
721
+ logger.info(f"Successfully reset to commit {commit_hash}")
722
+ return True
723
+
724
+ logger.error("Reset verification failed")
725
+ return False
726
+
727
+ except Exception as e:
728
+ logger.error(f"Failed to reset to commit: {e}", exc_info=True)
729
+ return False
730
+
731
+ def get_unpushed_commits(self) -> List[str]:
732
+ """
733
+ Get list of unpushed commit hashes.
734
+
735
+ Returns:
736
+ List of commit hashes that haven't been pushed
737
+ """
738
+ if not self.has_remote():
739
+ return []
740
+
741
+ # Get current branch name
742
+ branch = self.get_current_branch()
743
+ if not branch:
744
+ logger.error("Cannot determine current branch")
745
+ return []
746
+
747
+ try:
748
+ # Fetch to update remote refs
749
+ self._run_git(
750
+ ["fetch", "origin"],
751
+ cwd=self.realign_dir,
752
+ check=False,
753
+ capture_output=True
754
+ )
755
+
756
+ # Check if remote branch exists
757
+ check_remote = self._run_git(
758
+ ["rev-parse", "--verify", f"origin/{branch}"],
759
+ cwd=self.realign_dir,
760
+ capture_output=True,
761
+ check=False
762
+ )
763
+
764
+ if check_remote.returncode != 0:
765
+ # Remote branch doesn't exist, all local commits are unpushed
766
+ result = self._run_git(
767
+ ["log", "--format=%H"],
768
+ cwd=self.realign_dir,
769
+ capture_output=True,
770
+ text=True,
771
+ check=False
772
+ )
773
+ else:
774
+ # Remote branch exists, get commits ahead of remote
775
+ result = self._run_git(
776
+ ["log", f"origin/{branch}..HEAD", "--format=%H"],
777
+ cwd=self.realign_dir,
778
+ capture_output=True,
779
+ text=True,
780
+ check=False
781
+ )
782
+
783
+ if result.returncode == 0:
784
+ commits = result.stdout.strip().split('\n')
785
+ return [c for c in commits if c]
786
+ return []
787
+
788
+ except Exception as e:
789
+ logger.error(f"Failed to get unpushed commits: {e}")
790
+ return []
791
+
792
+ def safe_push(self, force: bool = False) -> bool:
793
+ """
794
+ Push commits to remote with conflict handling.
795
+
796
+ For members of shared repositories, pushes to their member branch.
797
+ Otherwise uses the current branch.
798
+
799
+ Args:
800
+ force: If True, force push (use with caution)
801
+
802
+ Returns:
803
+ True if successful, False otherwise
804
+ """
805
+ if not self.has_remote():
806
+ logger.error("No remote configured")
807
+ return False
808
+
809
+ # Check if this is a member branch scenario
810
+ member_branch = self.get_member_branch()
811
+ if member_branch:
812
+ # Member of shared repository - push to their specific branch
813
+ branch = member_branch
814
+ logger.info(f"Using member branch for push: {branch}")
815
+ else:
816
+ # Regular repository - use current branch
817
+ branch = self.get_current_branch()
818
+ if not branch:
819
+ logger.error("Cannot determine current branch")
820
+ return False
821
+
822
+ try:
823
+ # Check if remote branch exists
824
+ check_remote = self._run_git(
825
+ ["rev-parse", "--verify", f"origin/{branch}"],
826
+ cwd=self.realign_dir,
827
+ capture_output=True,
828
+ check=False
829
+ )
830
+
831
+ # Try push
832
+ push_cmd = ["push"]
833
+
834
+ # Set upstream if remote branch doesn't exist (first push)
835
+ if check_remote.returncode != 0:
836
+ push_cmd.extend(["-u", "origin", branch])
837
+ else:
838
+ push_cmd.extend(["origin", branch])
839
+
840
+ if force:
841
+ push_cmd.append("--force")
842
+
843
+ result = self._run_git(
844
+ push_cmd,
845
+ cwd=self.realign_dir,
846
+ check=False,
847
+ capture_output=True,
848
+ text=True
849
+ )
850
+
851
+ if result.returncode == 0:
852
+ logger.info("Successfully pushed to remote")
853
+ return True
854
+
855
+ # Push failed - try pull and merge
856
+ logger.info("Push rejected, attempting to pull and merge...")
857
+
858
+ # Pull with merge strategy (not rebase)
859
+ pull_result = self._run_git(
860
+ ["pull", "--no-rebase", "origin", branch],
861
+ cwd=self.realign_dir,
862
+ check=False,
863
+ capture_output=True,
864
+ text=True
865
+ )
866
+
867
+ if pull_result.returncode != 0:
868
+ # Check for conflicts
869
+ if "CONFLICT" in pull_result.stdout or "CONFLICT" in pull_result.stderr:
870
+ logger.info("Conflicts detected, attempting auto-resolution...")
871
+ if not self._auto_resolve_session_conflicts():
872
+ logger.error("Failed to auto-resolve conflicts")
873
+ return False
874
+
875
+ # Commit merge resolution
876
+ self._run_git(
877
+ ["add", "-A"],
878
+ cwd=self.realign_dir,
879
+ check=True
880
+ )
881
+ self._run_git(
882
+ ["commit", "--no-edit"],
883
+ cwd=self.realign_dir,
884
+ check=True
885
+ )
886
+ else:
887
+ logger.error(f"Pull failed: {pull_result.stderr}")
888
+ return False
889
+
890
+ # Retry push
891
+ retry_result = self._run_git(
892
+ push_cmd,
893
+ cwd=self.realign_dir,
894
+ check=False,
895
+ capture_output=True,
896
+ text=True
897
+ )
898
+
899
+ if retry_result.returncode == 0:
900
+ logger.info("Successfully pushed after merge")
901
+ return True
902
+ else:
903
+ logger.error(f"Push failed after merge: {retry_result.stderr}")
904
+ return False
905
+
906
+ except Exception as e:
907
+ logger.error(f"Failed to push: {e}", exc_info=True)
908
+ return False
909
+
910
+ def safe_pull(self) -> bool:
911
+ """
912
+ Pull updates from remote with conflict handling.
913
+
914
+ For members of shared repositories, pulls from their member branch.
915
+ Otherwise uses the current branch.
916
+
917
+ Returns:
918
+ True if successful, False otherwise
919
+ """
920
+ if not self.has_remote():
921
+ logger.error("No remote configured")
922
+ return False
923
+
924
+ # Check if this is a member branch scenario
925
+ member_branch = self.get_member_branch()
926
+ if member_branch:
927
+ # Member of shared repository - pull from their specific branch
928
+ branch = member_branch
929
+ logger.info(f"Using member branch: {branch}")
930
+ else:
931
+ # Regular repository - use current branch
932
+ branch = self.get_current_branch()
933
+ if not branch:
934
+ # If no branch (empty repository), use master as default
935
+ # This is the standard default branch name in git
936
+ branch = "master"
937
+ logger.debug("No current branch found, using default 'master'")
938
+
939
+ try:
940
+ # Pull with merge strategy
941
+ result = self._run_git(
942
+ ["pull", "--no-rebase", "origin", branch],
943
+ cwd=self.realign_dir,
944
+ check=False,
945
+ capture_output=True,
946
+ text=True
947
+ )
948
+
949
+ if result.returncode == 0:
950
+ logger.info("Successfully pulled from remote")
951
+ return True
952
+
953
+ # Check for conflicts
954
+ if "CONFLICT" in result.stdout or "CONFLICT" in result.stderr:
955
+ logger.info("Conflicts detected, attempting auto-resolution...")
956
+ if not self._auto_resolve_session_conflicts():
957
+ logger.error("Failed to auto-resolve conflicts")
958
+ return False
959
+
960
+ # Commit merge resolution
961
+ self._run_git(
962
+ ["add", "-A"],
963
+ cwd=self.realign_dir,
964
+ check=True
965
+ )
966
+ self._run_git(
967
+ ["commit", "--no-edit"],
968
+ cwd=self.realign_dir,
969
+ check=True
970
+ )
971
+ logger.info("Successfully resolved conflicts and completed pull")
972
+ return True
973
+ else:
974
+ logger.error(f"Pull failed: {result.stderr}")
975
+ return False
976
+
977
+ except Exception as e:
978
+ logger.error(f"Failed to pull: {e}", exc_info=True)
979
+ return False
980
+
981
+ def _auto_resolve_session_conflicts(self) -> bool:
982
+ """
983
+ Automatically resolve conflicts in session files.
984
+
985
+ Strategy:
986
+ - Session files: Keep both versions (rename conflicted one)
987
+ - Config files: Require manual resolution
988
+
989
+ Returns:
990
+ True if all conflicts resolved, False if manual intervention needed
991
+ """
992
+ try:
993
+ # Get list of conflicted files
994
+ result = self._run_git(
995
+ ["diff", "--name-only", "--diff-filter=U"],
996
+ cwd=self.realign_dir,
997
+ capture_output=True,
998
+ text=True,
999
+ check=True
1000
+ )
1001
+
1002
+ conflicted_files = result.stdout.strip().split('\n')
1003
+ conflicted_files = [f for f in conflicted_files if f]
1004
+
1005
+ if not conflicted_files:
1006
+ return True
1007
+
1008
+ for file_path_str in conflicted_files:
1009
+ file_path = Path(file_path_str)
1010
+
1011
+ # Check if it's a session file
1012
+ if file_path.parts[0] == 'sessions' and file_path.suffix == '.jsonl':
1013
+ # Session file - rename conflicted version
1014
+ import time
1015
+ timestamp = int(time.time())
1016
+ base_name = file_path.stem
1017
+ new_name = f"{base_name}_conflict_{timestamp}.jsonl"
1018
+
1019
+ full_path = self.realign_dir / file_path
1020
+ new_path = full_path.parent / new_name
1021
+
1022
+ # Resolve by keeping both versions
1023
+ # Git creates conflict markers, we'll use theirs version and rename ours
1024
+ self._run_git(
1025
+ ["checkout", "--theirs", str(file_path)],
1026
+ cwd=self.realign_dir,
1027
+ check=True
1028
+ )
1029
+
1030
+ logger.info(f"Auto-resolved session conflict: {file_path}")
1031
+
1032
+ elif file_path.name == 'config.yaml':
1033
+ # Config file - require manual resolution
1034
+ logger.error(f"Config file conflict requires manual resolution: {file_path}")
1035
+ print(f"\n⚠️ Config file conflict: {file_path}")
1036
+ print("Please resolve manually and run: git add <file> && git commit\n")
1037
+ return False
1038
+
1039
+ else:
1040
+ # Other files - use theirs version by default
1041
+ self._run_git(
1042
+ ["checkout", "--theirs", str(file_path)],
1043
+ cwd=self.realign_dir,
1044
+ check=True
1045
+ )
1046
+ logger.info(f"Auto-resolved conflict (using remote version): {file_path}")
1047
+
1048
+ return True
1049
+
1050
+ except Exception as e:
1051
+ logger.error(f"Failed to auto-resolve conflicts: {e}", exc_info=True)
1052
+ return False
1053
+
1054
+ def _stash_untracked_files(self) -> None:
1055
+ """
1056
+ Handle untracked files that might conflict during pull.
1057
+
1058
+ This is necessary because config files (config.yaml, .gitignore) might be created
1059
+ locally before pulling from remote, causing "untracked files would be overwritten" errors.
1060
+ """
1061
+ try:
1062
+ # Get list of untracked files
1063
+ result = self._run_git(
1064
+ ["status", "--porcelain"],
1065
+ cwd=self.realign_dir,
1066
+ check=True,
1067
+ capture_output=True,
1068
+ text=True
1069
+ )
1070
+
1071
+ untracked_files = [
1072
+ line.split(maxsplit=1)[1]
1073
+ for line in result.stdout.strip().split('\n')
1074
+ if line and line.startswith('??')
1075
+ ]
1076
+
1077
+ if not untracked_files:
1078
+ return
1079
+
1080
+ # Try to stash, but if it fails (e.g., no initial commit), delete them
1081
+ # They will be recreated/restored from remote
1082
+ stash_result = self._run_git(
1083
+ ["stash", "push", "--include-untracked"] + untracked_files,
1084
+ cwd=self.realign_dir,
1085
+ check=False,
1086
+ capture_output=True,
1087
+ text=True
1088
+ )
1089
+
1090
+ if stash_result.returncode == 0:
1091
+ logger.info(f"Stashed {len(untracked_files)} untracked file(s) before pull")
1092
+ else:
1093
+ # If stash fails, remove the untracked files instead
1094
+ # They will be restored from the remote repository during pull
1095
+ for file_path in untracked_files:
1096
+ full_path = self.realign_dir / file_path
1097
+ if full_path.exists():
1098
+ full_path.unlink()
1099
+ logger.info(f"Removed untracked file to allow pull: {file_path}")
1100
+
1101
+ except Exception as e:
1102
+ logger.warning(f"Failed to handle untracked files: {e}")
1103
+ # Continue anyway, as this is not critical
1104
+
1105
+ def _run_git(self, cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
1106
+ """
1107
+ Execute a git command.
1108
+
1109
+ Args:
1110
+ cmd: Git command and arguments (e.g., ["status", "--porcelain"])
1111
+ **kwargs: Additional arguments for subprocess.run()
1112
+
1113
+ Returns:
1114
+ CompletedProcess instance
1115
+ """
1116
+ full_cmd = ["git"] + cmd
1117
+
1118
+ # Ensure cwd is set
1119
+ if "cwd" not in kwargs:
1120
+ kwargs["cwd"] = self.realign_dir
1121
+
1122
+ logger.debug(f"Running git command: {' '.join(full_cmd)}")
1123
+ return subprocess.run(full_cmd, **kwargs)