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/review.py
CHANGED
|
@@ -15,6 +15,22 @@ from typing import List, Dict, Tuple, Optional
|
|
|
15
15
|
|
|
16
16
|
from ..logging_config import setup_logger
|
|
17
17
|
|
|
18
|
+
# ANSI color codes for terminal output
|
|
19
|
+
class Colors:
|
|
20
|
+
RESET = '\033[0m'
|
|
21
|
+
BOLD = '\033[1m'
|
|
22
|
+
DIM = '\033[2m'
|
|
23
|
+
|
|
24
|
+
# Foreground colors
|
|
25
|
+
RED = '\033[91m'
|
|
26
|
+
GREEN = '\033[92m'
|
|
27
|
+
YELLOW = '\033[93m'
|
|
28
|
+
BLUE = '\033[94m'
|
|
29
|
+
MAGENTA = '\033[95m'
|
|
30
|
+
CYAN = '\033[96m'
|
|
31
|
+
WHITE = '\033[97m'
|
|
32
|
+
GRAY = '\033[90m'
|
|
33
|
+
|
|
18
34
|
logger = setup_logger('realign.commands.review', 'review.log')
|
|
19
35
|
|
|
20
36
|
|
|
@@ -27,6 +43,7 @@ class UnpushedCommit:
|
|
|
27
43
|
message: str # First line of commit message
|
|
28
44
|
timestamp: datetime # Commit timestamp
|
|
29
45
|
llm_summary: str # Extracted LLM summary
|
|
46
|
+
user_request: Optional[str] # User's request text
|
|
30
47
|
session_files: List[str] # Session files modified
|
|
31
48
|
session_additions: Dict[str, List[Tuple[int, int]]] # {file: [(start, end), ...]}
|
|
32
49
|
has_sensitive: bool = False # Whether sensitive content detected
|
|
@@ -36,10 +53,14 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
|
|
|
36
53
|
"""
|
|
37
54
|
Get all unpushed commits from the current branch.
|
|
38
55
|
|
|
39
|
-
Strategy (
|
|
40
|
-
1.
|
|
41
|
-
2.
|
|
42
|
-
|
|
56
|
+
Strategy (Updated):
|
|
57
|
+
1. Get current branch name
|
|
58
|
+
2. If on main/master branch:
|
|
59
|
+
- Use upstream (@{u}) if exists, otherwise origin/main
|
|
60
|
+
3. If on feature branch:
|
|
61
|
+
- Always compare against origin/main or origin/master
|
|
62
|
+
- This shows all commits that will be in the PR
|
|
63
|
+
4. If no remote exists, show all commits on current branch
|
|
43
64
|
|
|
44
65
|
Args:
|
|
45
66
|
repo_root: Path to repository root
|
|
@@ -49,35 +70,58 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
|
|
|
49
70
|
"""
|
|
50
71
|
logger.info("Getting unpushed commits")
|
|
51
72
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
["git", "rev-parse", "--abbrev-ref", "
|
|
73
|
+
# Get current branch name
|
|
74
|
+
current_branch_result = subprocess.run(
|
|
75
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
55
76
|
cwd=repo_root,
|
|
56
77
|
capture_output=True,
|
|
57
78
|
text=True
|
|
58
79
|
)
|
|
59
80
|
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
base = f"origin/{main_branch}"
|
|
67
|
-
logger.debug(f"No upstream found, using fallback: {base}")
|
|
81
|
+
if current_branch_result.returncode != 0:
|
|
82
|
+
logger.error("Failed to get current branch name")
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
current_branch = current_branch_result.stdout.strip()
|
|
86
|
+
logger.debug(f"Current branch: {current_branch}")
|
|
68
87
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
# Detect main branch name
|
|
89
|
+
main_branch = detect_main_branch(repo_root)
|
|
90
|
+
|
|
91
|
+
# Determine base branch based on current branch
|
|
92
|
+
if current_branch in ['main', 'master']:
|
|
93
|
+
# On main branch: show commits not pushed to upstream
|
|
94
|
+
upstream_result = subprocess.run(
|
|
95
|
+
["git", "rev-parse", "--abbrev-ref", "@{u}"],
|
|
72
96
|
cwd=repo_root,
|
|
73
97
|
capture_output=True,
|
|
74
98
|
text=True
|
|
75
99
|
)
|
|
76
100
|
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
if upstream_result.returncode == 0:
|
|
102
|
+
base = "@{u}"
|
|
103
|
+
logger.debug(f"On main branch, using upstream: {upstream_result.stdout.strip()}")
|
|
104
|
+
else:
|
|
105
|
+
base = f"origin/{main_branch}"
|
|
106
|
+
logger.debug(f"On main branch but no upstream, using: {base}")
|
|
107
|
+
else:
|
|
108
|
+
# On feature branch: show all commits relative to main
|
|
109
|
+
# This shows what would be in a PR
|
|
110
|
+
base = f"origin/{main_branch}"
|
|
111
|
+
logger.debug(f"On feature branch '{current_branch}', comparing against: {base}")
|
|
112
|
+
|
|
113
|
+
# Verify that the base branch exists
|
|
114
|
+
verify_result = subprocess.run(
|
|
115
|
+
["git", "rev-parse", "--verify", base],
|
|
116
|
+
cwd=repo_root,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if verify_result.returncode != 0:
|
|
122
|
+
# Remote branch doesn't exist, show all commits on current branch
|
|
123
|
+
base = None
|
|
124
|
+
logger.info(f"Base branch '{base}' not found, will show all commits on current branch")
|
|
81
125
|
|
|
82
126
|
# Get commit list
|
|
83
127
|
# Format: full_hash|short_hash|subject|timestamp
|
|
@@ -125,8 +169,9 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
|
|
|
125
169
|
text=True
|
|
126
170
|
).stdout
|
|
127
171
|
|
|
128
|
-
# Extract LLM summary
|
|
172
|
+
# Extract LLM summary and user request
|
|
129
173
|
llm_summary = extract_llm_summary(full_message)
|
|
174
|
+
user_request = extract_user_request(full_message)
|
|
130
175
|
|
|
131
176
|
# Get session file additions
|
|
132
177
|
session_files, session_additions = get_session_additions(full_hash, repo_root)
|
|
@@ -138,6 +183,7 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
|
|
|
138
183
|
message=subject,
|
|
139
184
|
timestamp=timestamp,
|
|
140
185
|
llm_summary=llm_summary,
|
|
186
|
+
user_request=user_request,
|
|
141
187
|
session_files=session_files,
|
|
142
188
|
session_additions=session_additions,
|
|
143
189
|
has_sensitive=False # Will be set by --detect-secrets flag
|
|
@@ -199,27 +245,85 @@ def detect_main_branch(repo_root: Path) -> str:
|
|
|
199
245
|
return "main"
|
|
200
246
|
|
|
201
247
|
|
|
248
|
+
def extract_user_request(commit_message: str) -> Optional[str]:
|
|
249
|
+
"""
|
|
250
|
+
Extract user request from commit message.
|
|
251
|
+
|
|
252
|
+
New format (v0.3+):
|
|
253
|
+
---
|
|
254
|
+
Session: ... | Turn: ... | Model: ...
|
|
255
|
+
Request: {user_message}
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
commit_message: Full commit message
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
User request text, or None if not found
|
|
262
|
+
"""
|
|
263
|
+
lines = commit_message.split('\n')
|
|
264
|
+
|
|
265
|
+
# Look for "Request:" line
|
|
266
|
+
for i, line in enumerate(lines):
|
|
267
|
+
if line.strip().startswith('Request:'):
|
|
268
|
+
# Extract text after "Request:"
|
|
269
|
+
request = line.strip()[8:].strip() # Remove "Request:" prefix
|
|
270
|
+
# Filter out placeholder messages
|
|
271
|
+
if request and request != "No user message found":
|
|
272
|
+
return request
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
202
277
|
def extract_llm_summary(commit_message: str) -> str:
|
|
203
278
|
"""
|
|
204
279
|
Extract LLM summary from commit message.
|
|
205
280
|
|
|
206
|
-
|
|
281
|
+
Supports two formats:
|
|
282
|
+
|
|
283
|
+
New format (v0.3+):
|
|
284
|
+
{llm_title}
|
|
285
|
+
|
|
286
|
+
{llm_description}
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
Turn: #{turn_number} | Model: {model_name}
|
|
290
|
+
Request: {user_message}
|
|
291
|
+
|
|
292
|
+
Legacy format:
|
|
207
293
|
chore: Auto-commit MCP session (2025-11-22 19:24:29)
|
|
208
294
|
|
|
209
295
|
--- LLM-Summary (claude-3-5-haiku) ---
|
|
210
296
|
* [Claude] Discussed implementing JWT authentication
|
|
211
|
-
* [Codex] Fixed bug in payment module
|
|
212
|
-
|
|
213
|
-
Agent-Redacted: false
|
|
214
297
|
|
|
215
298
|
Args:
|
|
216
299
|
commit_message: Full commit message
|
|
217
300
|
|
|
218
301
|
Returns:
|
|
219
|
-
Extracted summary text
|
|
302
|
+
Extracted summary text, or "(No summary)"
|
|
220
303
|
"""
|
|
221
304
|
lines = commit_message.split('\n')
|
|
222
305
|
|
|
306
|
+
# Try new format first: title is first line, description is in the middle
|
|
307
|
+
if lines and not lines[0].startswith('chore:') and '---' in commit_message:
|
|
308
|
+
# New format: {title}\n\n{description}\n\n---
|
|
309
|
+
title = lines[0].strip()
|
|
310
|
+
|
|
311
|
+
# Find description (between title and ---)
|
|
312
|
+
description_lines = []
|
|
313
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
314
|
+
if line.strip() == '---':
|
|
315
|
+
break
|
|
316
|
+
if line.strip(): # Skip empty lines
|
|
317
|
+
description_lines.append(line.strip())
|
|
318
|
+
|
|
319
|
+
if title:
|
|
320
|
+
summary = title
|
|
321
|
+
if description_lines:
|
|
322
|
+
# Use first line of description if available
|
|
323
|
+
summary += f": {description_lines[0]}"
|
|
324
|
+
return summary
|
|
325
|
+
|
|
326
|
+
# Try legacy format
|
|
223
327
|
in_summary = False
|
|
224
328
|
summary_lines = []
|
|
225
329
|
|
|
@@ -282,9 +386,11 @@ def get_session_additions(commit_hash: str, repo_root: Path) -> Tuple[List[str],
|
|
|
282
386
|
all_files = files_result.stdout.strip().split('\n')
|
|
283
387
|
|
|
284
388
|
# Filter session files
|
|
389
|
+
# Note: In shadow git, session files are in 'sessions/' directory
|
|
390
|
+
# In user project, they would be in '.realign/sessions/' directory
|
|
285
391
|
session_files = [
|
|
286
392
|
f for f in all_files
|
|
287
|
-
if f.startswith('.realign/sessions/') and f.endswith('.jsonl')
|
|
393
|
+
if (f.startswith('sessions/') or f.startswith('.realign/sessions/')) and f.endswith('.jsonl')
|
|
288
394
|
]
|
|
289
395
|
|
|
290
396
|
if not session_files:
|
|
@@ -388,32 +494,61 @@ def display_unpushed_commits(commits: List[UnpushedCommit], verbose: bool = Fals
|
|
|
388
494
|
verbose: Whether to show detailed information
|
|
389
495
|
"""
|
|
390
496
|
if not commits:
|
|
391
|
-
print("\n✓ No unpushed commits found
|
|
497
|
+
print(f"\n{Colors.GREEN}✓ No unpushed commits found.{Colors.RESET}\n")
|
|
392
498
|
return
|
|
393
499
|
|
|
394
|
-
|
|
500
|
+
# Header
|
|
501
|
+
print(f"\n{Colors.BOLD}{Colors.CYAN}📋 Unpushed commits ({len(commits)}){Colors.RESET}\n")
|
|
395
502
|
|
|
396
503
|
for commit in commits:
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
504
|
+
# Index and hash in gray
|
|
505
|
+
index_str = f"{Colors.GRAY}[{commit.index}]{Colors.RESET}"
|
|
506
|
+
hash_str = f"{Colors.YELLOW}{commit.hash}{Colors.RESET}"
|
|
507
|
+
|
|
508
|
+
# Commit message (first line only) in bold white
|
|
509
|
+
message_str = f"{Colors.BOLD}{commit.message}{Colors.RESET}"
|
|
510
|
+
|
|
511
|
+
print(f"{index_str} {hash_str} {message_str}")
|
|
512
|
+
|
|
513
|
+
# User request (first 50 characters) in cyan
|
|
514
|
+
if commit.user_request:
|
|
515
|
+
# Truncate to 50 characters
|
|
516
|
+
request_display = commit.user_request[:50]
|
|
517
|
+
if len(commit.user_request) > 50:
|
|
518
|
+
request_display += "..."
|
|
519
|
+
print(f" {Colors.CYAN}└─ {request_display}{Colors.RESET}")
|
|
520
|
+
|
|
521
|
+
# Verbose mode: show additional details
|
|
522
|
+
if verbose:
|
|
523
|
+
# Timestamp in dim gray
|
|
524
|
+
time_str = commit.timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
|
525
|
+
print(f" {Colors.DIM}Time: {time_str}{Colors.RESET}")
|
|
526
|
+
|
|
527
|
+
# LLM Summary
|
|
528
|
+
if commit.llm_summary and commit.llm_summary != "(No summary)":
|
|
529
|
+
print(f" {Colors.DIM}Summary: {commit.llm_summary}{Colors.RESET}")
|
|
530
|
+
|
|
531
|
+
# Session files (only show files with additions)
|
|
532
|
+
if commit.session_files:
|
|
533
|
+
files_with_additions = [
|
|
534
|
+
(f, commit.session_additions.get(f, []))
|
|
535
|
+
for f in commit.session_files
|
|
536
|
+
]
|
|
537
|
+
# Filter to only files with actual additions
|
|
538
|
+
files_with_additions = [
|
|
539
|
+
(f, ranges) for f, ranges in files_with_additions
|
|
540
|
+
if ranges # Only show files with non-empty additions
|
|
541
|
+
]
|
|
542
|
+
|
|
543
|
+
for session_file, additions in files_with_additions:
|
|
544
|
+
total_lines = sum(end - start + 1 for start, end in additions)
|
|
545
|
+
print(f" {Colors.DIM}Session: {session_file} (+{total_lines} lines){Colors.RESET}")
|
|
546
|
+
|
|
547
|
+
# Sensitive content warning
|
|
548
|
+
if commit.has_sensitive:
|
|
549
|
+
print(f" {Colors.RED}⚠️ WARNING: Potential sensitive content detected{Colors.RESET}")
|
|
550
|
+
|
|
551
|
+
print() # Blank line separator in verbose mode
|
|
417
552
|
|
|
418
553
|
|
|
419
554
|
def review_command(
|
|
@@ -424,8 +559,11 @@ def review_command(
|
|
|
424
559
|
"""
|
|
425
560
|
Main entry point for review command.
|
|
426
561
|
|
|
562
|
+
Reviews commits in the shadow git repository (~/.aline/{project}/.git)
|
|
563
|
+
rather than the user's project repository.
|
|
564
|
+
|
|
427
565
|
Args:
|
|
428
|
-
repo_root: Path to
|
|
566
|
+
repo_root: Path to user's project root (auto-detected if None)
|
|
429
567
|
verbose: Show detailed information
|
|
430
568
|
detect_secrets: Run sensitive content detection
|
|
431
569
|
|
|
@@ -434,7 +572,7 @@ def review_command(
|
|
|
434
572
|
"""
|
|
435
573
|
logger.info("======== Review command started ========")
|
|
436
574
|
|
|
437
|
-
# Auto-detect
|
|
575
|
+
# Auto-detect user project root if not provided
|
|
438
576
|
if repo_root is None:
|
|
439
577
|
try:
|
|
440
578
|
result = subprocess.run(
|
|
@@ -444,12 +582,36 @@ def review_command(
|
|
|
444
582
|
check=True
|
|
445
583
|
)
|
|
446
584
|
repo_root = Path(result.stdout.strip())
|
|
447
|
-
logger.debug(f"Detected
|
|
585
|
+
logger.debug(f"Detected user project root: {repo_root}")
|
|
448
586
|
except subprocess.CalledProcessError:
|
|
449
587
|
print("Error: Not in a git repository", file=sys.stderr)
|
|
450
588
|
logger.error("Not in a git repository")
|
|
451
589
|
return 1
|
|
452
590
|
|
|
591
|
+
# Get shadow git repository path
|
|
592
|
+
from .. import get_realign_dir
|
|
593
|
+
shadow_dir = get_realign_dir(repo_root)
|
|
594
|
+
shadow_git = shadow_dir
|
|
595
|
+
|
|
596
|
+
# Verify shadow git exists
|
|
597
|
+
if not shadow_git.exists():
|
|
598
|
+
print(f"Error: Shadow git repository not found at {shadow_git}", file=sys.stderr)
|
|
599
|
+
print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
|
|
600
|
+
logger.error(f"Shadow git not found at {shadow_git}")
|
|
601
|
+
return 1
|
|
602
|
+
|
|
603
|
+
# Check if it's a git repository
|
|
604
|
+
git_dir = shadow_git / '.git'
|
|
605
|
+
if not git_dir.exists():
|
|
606
|
+
print(f"Error: {shadow_git} is not a git repository", file=sys.stderr)
|
|
607
|
+
print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
|
|
608
|
+
logger.error(f"No .git found in {shadow_git}")
|
|
609
|
+
return 1
|
|
610
|
+
|
|
611
|
+
logger.info(f"Using shadow git repository: {shadow_git}")
|
|
612
|
+
# Override repo_root to point to shadow git
|
|
613
|
+
repo_root = shadow_git
|
|
614
|
+
|
|
453
615
|
# Get unpushed commits
|
|
454
616
|
try:
|
|
455
617
|
commits = get_unpushed_commits(repo_root)
|
|
@@ -7,15 +7,26 @@ from pathlib import Path
|
|
|
7
7
|
from typing import List
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def find_session_paths_for_commit(repo_root: Path, commit_hash: str) -> List[str]:
|
|
11
|
-
"""
|
|
10
|
+
def find_session_paths_for_commit(repo_root: Path, commit_hash: str, git_dir: Path = None) -> List[str]:
|
|
11
|
+
"""
|
|
12
|
+
Return relative session file paths tracked in a given commit.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
repo_root: Root directory (used if git_dir not provided)
|
|
16
|
+
commit_hash: Commit hash to query
|
|
17
|
+
git_dir: Optional git directory to use (for .aline repos)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
List of relative session file paths
|
|
21
|
+
"""
|
|
12
22
|
try:
|
|
23
|
+
cwd = git_dir if git_dir else repo_root
|
|
13
24
|
result = subprocess.run(
|
|
14
25
|
["git", "show", "--pretty=format:", "--name-only", commit_hash],
|
|
15
26
|
capture_output=True,
|
|
16
27
|
text=True,
|
|
17
28
|
check=True,
|
|
18
|
-
cwd=
|
|
29
|
+
cwd=cwd,
|
|
19
30
|
)
|
|
20
31
|
except subprocess.CalledProcessError:
|
|
21
32
|
return []
|
|
@@ -23,6 +34,130 @@ def find_session_paths_for_commit(repo_root: Path, commit_hash: str) -> List[str
|
|
|
23
34
|
paths = []
|
|
24
35
|
for line in result.stdout.splitlines():
|
|
25
36
|
candidate = line.strip()
|
|
26
|
-
|
|
37
|
+
# Support both .realign/sessions/ and sessions/ patterns
|
|
38
|
+
if (candidate.startswith(".realign/sessions/") or candidate.startswith("sessions/")) and candidate.endswith(".jsonl"):
|
|
27
39
|
paths.append(candidate)
|
|
28
40
|
return paths
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_original_session_location(session_filename: str, project_root: Path, commit_timestamp: int) -> Path:
|
|
44
|
+
"""
|
|
45
|
+
Detect the original location where a session file should be restored.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
session_filename: Name of session file (e.g., "huminhao_claude_637f35af.jsonl" or "uuid.jsonl")
|
|
49
|
+
project_root: Root directory of the user's project
|
|
50
|
+
commit_timestamp: Unix timestamp of the commit
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path where session should be restored, or None if cannot detect
|
|
54
|
+
"""
|
|
55
|
+
from datetime import datetime
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
# Check if this is a UUID format (Claude Code original format)
|
|
59
|
+
# UUID pattern: 8-4-4-4-12 hex digits
|
|
60
|
+
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$'
|
|
61
|
+
if re.match(uuid_pattern, session_filename, re.IGNORECASE):
|
|
62
|
+
# This is a UUID format session file, likely from Claude Code
|
|
63
|
+
try:
|
|
64
|
+
from ..claude_detector import find_claude_sessions_dir
|
|
65
|
+
claude_dir = find_claude_sessions_dir(project_root)
|
|
66
|
+
if claude_dir and claude_dir.exists():
|
|
67
|
+
return claude_dir / session_filename
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
# Fall through to return None if Claude dir not found
|
|
71
|
+
|
|
72
|
+
else:
|
|
73
|
+
# Parse filename to extract agent type
|
|
74
|
+
# Expected format: {user}_{agent}_{hash}.jsonl
|
|
75
|
+
parts = session_filename.replace('.jsonl', '').split('_')
|
|
76
|
+
|
|
77
|
+
if len(parts) >= 3:
|
|
78
|
+
agent_type = parts[1].lower() # e.g., "claude", "codex"
|
|
79
|
+
|
|
80
|
+
if agent_type == "claude":
|
|
81
|
+
# Try to find Claude sessions directory
|
|
82
|
+
try:
|
|
83
|
+
from ..claude_detector import find_claude_sessions_dir
|
|
84
|
+
claude_dir = find_claude_sessions_dir(project_root)
|
|
85
|
+
if claude_dir and claude_dir.exists():
|
|
86
|
+
return claude_dir / session_filename
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
elif agent_type == "codex":
|
|
91
|
+
# Construct Codex path from timestamp
|
|
92
|
+
try:
|
|
93
|
+
dt = datetime.fromtimestamp(int(commit_timestamp))
|
|
94
|
+
codex_dir = Path.home() / ".codex" / "sessions" / dt.strftime("%Y") / dt.strftime("%m") / dt.strftime("%d")
|
|
95
|
+
if codex_dir.exists():
|
|
96
|
+
# Codex uses rollout-* naming, but we'll use our renamed format
|
|
97
|
+
return codex_dir / session_filename
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Fall back to None if cannot detect
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def restore_session_from_commit(tracker, commit_hash: str, session_rel_path: str, dest_path: Path) -> bool:
|
|
106
|
+
"""
|
|
107
|
+
Restore a session file from a commit to its original location.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
tracker: ReAlignGitTracker instance
|
|
111
|
+
commit_hash: Commit hash to restore from
|
|
112
|
+
session_rel_path: Relative path in git (e.g., "sessions/user_claude_hash.jsonl")
|
|
113
|
+
dest_path: Destination path to restore to
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if successful, False otherwise
|
|
117
|
+
"""
|
|
118
|
+
import json
|
|
119
|
+
from datetime import datetime
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Extract session content from commit
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["git", "show", f"{commit_hash}:{session_rel_path}"],
|
|
125
|
+
cwd=tracker.realign_dir,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
check=False
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
content = result.stdout.decode('utf-8', errors='replace')
|
|
134
|
+
|
|
135
|
+
# Validate JSONL format (basic check)
|
|
136
|
+
try:
|
|
137
|
+
for line in content.splitlines():
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if line: # Skip empty lines
|
|
140
|
+
json.loads(line) # Will raise if invalid JSON
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
# Invalid JSONL, create .corrupted file
|
|
143
|
+
corrupted_path = dest_path.with_suffix('.jsonl.corrupted')
|
|
144
|
+
corrupted_path.write_text(content, encoding='utf-8')
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# If destination exists, create backup
|
|
148
|
+
if dest_path.exists():
|
|
149
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
150
|
+
backup_name = dest_path.stem + f".backup-{timestamp}" + dest_path.suffix
|
|
151
|
+
backup_path = dest_path.parent / backup_name
|
|
152
|
+
dest_path.rename(backup_path)
|
|
153
|
+
|
|
154
|
+
# Create parent directory if needed
|
|
155
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
# Write session content
|
|
158
|
+
dest_path.write_text(content, encoding='utf-8')
|
|
159
|
+
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
except Exception:
|
|
163
|
+
return False
|