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
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
"""Share commands - Manage session sharing and collaboration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import webbrowser
|
|
5
|
+
import getpass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ..tracker.git_tracker import ReAlignGitTracker
|
|
13
|
+
from ..logging_config import setup_logger
|
|
14
|
+
|
|
15
|
+
logger = setup_logger('realign.commands.share', 'share.log')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def share_configure_command(
|
|
19
|
+
remote: str,
|
|
20
|
+
token: Optional[str] = None,
|
|
21
|
+
repo_root: Optional[Path] = None
|
|
22
|
+
) -> int:
|
|
23
|
+
"""
|
|
24
|
+
Manually configure remote repository for sharing.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
remote: Remote repository (e.g., user/repo or full URL)
|
|
28
|
+
token: GitHub access token (optional)
|
|
29
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Exit code (0 for success, 1 for error)
|
|
33
|
+
"""
|
|
34
|
+
# Get project root
|
|
35
|
+
if repo_root is None:
|
|
36
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
37
|
+
|
|
38
|
+
# Initialize tracker
|
|
39
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
40
|
+
|
|
41
|
+
# Check if repository is initialized
|
|
42
|
+
if not tracker.is_initialized():
|
|
43
|
+
print("❌ Repository not initialized")
|
|
44
|
+
print("Run 'aline init' first")
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
# Parse remote URL
|
|
48
|
+
remote_url = _parse_remote_url(remote)
|
|
49
|
+
|
|
50
|
+
if not remote_url:
|
|
51
|
+
print("❌ Invalid remote format")
|
|
52
|
+
print("\nExpected formats:")
|
|
53
|
+
print(" user/repo")
|
|
54
|
+
print(" https://github.com/user/repo.git")
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
# Store token in git credentials if provided
|
|
58
|
+
if token:
|
|
59
|
+
_store_git_credentials(remote_url, token)
|
|
60
|
+
|
|
61
|
+
# Configure remote
|
|
62
|
+
print(f"Configuring remote: {remote_url}")
|
|
63
|
+
|
|
64
|
+
success = tracker.setup_remote(remote_url)
|
|
65
|
+
|
|
66
|
+
if not success:
|
|
67
|
+
print("❌ Failed to configure remote")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
print("✓ Remote configured successfully")
|
|
71
|
+
|
|
72
|
+
# Try initial push
|
|
73
|
+
print("\nAttempting initial push...")
|
|
74
|
+
|
|
75
|
+
push_success = tracker.safe_push()
|
|
76
|
+
|
|
77
|
+
if push_success:
|
|
78
|
+
print("✓ Successfully pushed to remote")
|
|
79
|
+
else:
|
|
80
|
+
print("⚠️ Initial push failed")
|
|
81
|
+
print("\nPossible issues:")
|
|
82
|
+
print(" - Repository doesn't exist (create it on GitHub first)")
|
|
83
|
+
print(" - No access permissions (check GitHub access token)")
|
|
84
|
+
print(" - Network issues")
|
|
85
|
+
print("\nYou can try pushing later with: aline push")
|
|
86
|
+
|
|
87
|
+
print(f"\nRemote: {remote_url}")
|
|
88
|
+
|
|
89
|
+
# Update config
|
|
90
|
+
_update_sharing_config(repo_root, remote_url, enabled=True)
|
|
91
|
+
|
|
92
|
+
print("\nNext steps:")
|
|
93
|
+
print(" - Push sessions: aline push")
|
|
94
|
+
print(" - Invite teammates: aline share invite <email>")
|
|
95
|
+
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def share_status_command(repo_root: Optional[Path] = None) -> int:
|
|
100
|
+
"""
|
|
101
|
+
Show current sharing configuration.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Exit code (0 for success, 1 for error)
|
|
108
|
+
"""
|
|
109
|
+
# Get project root
|
|
110
|
+
if repo_root is None:
|
|
111
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
112
|
+
|
|
113
|
+
# Initialize tracker
|
|
114
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
115
|
+
|
|
116
|
+
if not tracker.is_initialized():
|
|
117
|
+
print("Repository not initialized")
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
# Check if remote is configured
|
|
121
|
+
remote_url = tracker.get_remote_url()
|
|
122
|
+
|
|
123
|
+
if not remote_url:
|
|
124
|
+
print("Sharing: Disabled")
|
|
125
|
+
print("\nTo enable sharing:")
|
|
126
|
+
print(" aline init --share (browser-based setup)")
|
|
127
|
+
print(" aline share configure (manual configuration)")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
print("Sharing: Enabled ✓")
|
|
131
|
+
print(f"Remote: {remote_url}")
|
|
132
|
+
|
|
133
|
+
# Get unpushed commits
|
|
134
|
+
unpushed = tracker.get_unpushed_commits()
|
|
135
|
+
print(f"Unpushed commits: {len(unpushed)}")
|
|
136
|
+
|
|
137
|
+
# Load config to show additional details
|
|
138
|
+
config = tracker.config.get('sharing', {})
|
|
139
|
+
|
|
140
|
+
if config.get('owner'):
|
|
141
|
+
print(f"Owner: {config['owner']}")
|
|
142
|
+
|
|
143
|
+
if config.get('created_at'):
|
|
144
|
+
print(f"Created: {config['created_at']}")
|
|
145
|
+
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def share_invite_command(
|
|
150
|
+
email: Optional[str] = None,
|
|
151
|
+
repo_root: Optional[Path] = None
|
|
152
|
+
) -> int:
|
|
153
|
+
"""
|
|
154
|
+
Invite collaborator to shared repository.
|
|
155
|
+
|
|
156
|
+
Opens GitHub collaboration settings page in browser.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
email: Email address to invite (optional, for display only)
|
|
160
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Exit code (0 for success, 1 for error)
|
|
164
|
+
"""
|
|
165
|
+
# Get project root
|
|
166
|
+
if repo_root is None:
|
|
167
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
168
|
+
|
|
169
|
+
# Initialize tracker
|
|
170
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
171
|
+
|
|
172
|
+
if not tracker.has_remote():
|
|
173
|
+
print("❌ No remote configured")
|
|
174
|
+
print("Run 'aline share configure' first")
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
# Get remote URL
|
|
178
|
+
remote_url = tracker.get_remote_url()
|
|
179
|
+
|
|
180
|
+
# Parse GitHub repo from URL
|
|
181
|
+
repo_path = _extract_github_repo(remote_url)
|
|
182
|
+
|
|
183
|
+
if not repo_path:
|
|
184
|
+
print("❌ Not a GitHub repository")
|
|
185
|
+
print(f"Remote: {remote_url}")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
# Construct GitHub collaborators URL
|
|
189
|
+
github_url = f"https://github.com/{repo_path}/settings/access"
|
|
190
|
+
|
|
191
|
+
print("Opening GitHub collaboration page...")
|
|
192
|
+
print(f"Repository: {repo_path}")
|
|
193
|
+
|
|
194
|
+
if email:
|
|
195
|
+
print(f"Inviting: {email}")
|
|
196
|
+
|
|
197
|
+
# Open browser
|
|
198
|
+
webbrowser.open(github_url)
|
|
199
|
+
|
|
200
|
+
print("\nOnce invited, they can join with:")
|
|
201
|
+
print(f" aline init --join {repo_path}")
|
|
202
|
+
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def share_link_command(repo_root: Optional[Path] = None) -> int:
|
|
207
|
+
"""
|
|
208
|
+
Get shareable link for teammates to join.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Exit code (0 for success, 1 for error)
|
|
215
|
+
"""
|
|
216
|
+
# Get project root
|
|
217
|
+
if repo_root is None:
|
|
218
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
219
|
+
|
|
220
|
+
# Initialize tracker
|
|
221
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
222
|
+
|
|
223
|
+
if not tracker.has_remote():
|
|
224
|
+
print("❌ No remote configured")
|
|
225
|
+
print("Run 'aline share configure' first")
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
# Get remote URL
|
|
229
|
+
remote_url = tracker.get_remote_url()
|
|
230
|
+
|
|
231
|
+
# Parse GitHub repo from URL
|
|
232
|
+
repo_path = _extract_github_repo(remote_url)
|
|
233
|
+
|
|
234
|
+
if not repo_path:
|
|
235
|
+
print(f"Remote URL: {remote_url}")
|
|
236
|
+
print("\nShare this URL with teammates")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
print("Share with teammates:")
|
|
240
|
+
print()
|
|
241
|
+
print(f"Repository: https://github.com/{repo_path}")
|
|
242
|
+
print()
|
|
243
|
+
print("Join command:")
|
|
244
|
+
print(f" aline init --join {repo_path}")
|
|
245
|
+
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def share_disable_command(repo_root: Optional[Path] = None) -> int:
|
|
250
|
+
"""
|
|
251
|
+
Disable sharing (keeps history intact).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Exit code (0 for success, 1 for error)
|
|
258
|
+
"""
|
|
259
|
+
# Get project root
|
|
260
|
+
if repo_root is None:
|
|
261
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
262
|
+
|
|
263
|
+
# Initialize tracker
|
|
264
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
265
|
+
|
|
266
|
+
if not tracker.has_remote():
|
|
267
|
+
print("Sharing is already disabled")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
remote_url = tracker.get_remote_url()
|
|
271
|
+
|
|
272
|
+
confirm = input(f"Remove remote: {remote_url}? [y/N]: ").strip().lower()
|
|
273
|
+
if confirm != 'y':
|
|
274
|
+
print("Cancelled")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
# Remove remote
|
|
278
|
+
try:
|
|
279
|
+
tracker._run_git(
|
|
280
|
+
["remote", "remove", "origin"],
|
|
281
|
+
cwd=tracker.realign_dir,
|
|
282
|
+
check=True
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
print("✓ Remote removed")
|
|
286
|
+
print("Local history preserved")
|
|
287
|
+
|
|
288
|
+
# Update config
|
|
289
|
+
_update_sharing_config(repo_root, None, enabled=False)
|
|
290
|
+
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"Failed to remove remote: {e}")
|
|
295
|
+
print("❌ Failed to remove remote")
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# Helper functions
|
|
300
|
+
|
|
301
|
+
def _parse_remote_url(remote: str) -> Optional[str]:
|
|
302
|
+
"""
|
|
303
|
+
Parse remote URL from various formats.
|
|
304
|
+
|
|
305
|
+
Supports:
|
|
306
|
+
- user/repo -> https://github.com/user/repo.git
|
|
307
|
+
- https://github.com/user/repo.git (unchanged)
|
|
308
|
+
- git@github.com:user/repo.git (unchanged)
|
|
309
|
+
"""
|
|
310
|
+
remote = remote.strip()
|
|
311
|
+
|
|
312
|
+
# Already a full URL
|
|
313
|
+
if remote.startswith('http://') or remote.startswith('https://') or remote.startswith('git@'):
|
|
314
|
+
return remote
|
|
315
|
+
|
|
316
|
+
# Short format: user/repo
|
|
317
|
+
if '/' in remote and not remote.startswith('/'):
|
|
318
|
+
parts = remote.split('/')
|
|
319
|
+
if len(parts) == 2:
|
|
320
|
+
user, repo = parts
|
|
321
|
+
# Remove .git suffix if present
|
|
322
|
+
repo = repo.replace('.git', '')
|
|
323
|
+
return f"https://github.com/{user}/{repo}.git"
|
|
324
|
+
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _extract_github_repo(url: str) -> Optional[str]:
|
|
329
|
+
"""
|
|
330
|
+
Extract GitHub repo path (user/repo) from URL.
|
|
331
|
+
|
|
332
|
+
Examples:
|
|
333
|
+
https://github.com/alice/myproject.git -> alice/myproject
|
|
334
|
+
git@github.com:alice/myproject.git -> alice/myproject
|
|
335
|
+
"""
|
|
336
|
+
url = url.strip()
|
|
337
|
+
|
|
338
|
+
# HTTPS URL
|
|
339
|
+
if 'github.com/' in url:
|
|
340
|
+
parts = url.split('github.com/')
|
|
341
|
+
if len(parts) == 2:
|
|
342
|
+
repo_path = parts[1]
|
|
343
|
+
# Remove .git suffix
|
|
344
|
+
repo_path = repo_path.replace('.git', '')
|
|
345
|
+
return repo_path
|
|
346
|
+
|
|
347
|
+
# SSH URL
|
|
348
|
+
if 'github.com:' in url:
|
|
349
|
+
parts = url.split('github.com:')
|
|
350
|
+
if len(parts) == 2:
|
|
351
|
+
repo_path = parts[1]
|
|
352
|
+
# Remove .git suffix
|
|
353
|
+
repo_path = repo_path.replace('.git', '')
|
|
354
|
+
return repo_path
|
|
355
|
+
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _get_github_username(token: str) -> Optional[str]:
|
|
360
|
+
"""
|
|
361
|
+
Get GitHub username from a personal access token using GitHub API.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
token: GitHub personal access token
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
GitHub username or None if failed
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
import urllib.request
|
|
371
|
+
import json
|
|
372
|
+
|
|
373
|
+
req = urllib.request.Request(
|
|
374
|
+
"https://api.github.com/user",
|
|
375
|
+
headers={"Authorization": f"token {token}"}
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
379
|
+
data = json.loads(response.read().decode())
|
|
380
|
+
username = data.get("login")
|
|
381
|
+
if username:
|
|
382
|
+
logger.info(f"Retrieved GitHub username: {username}")
|
|
383
|
+
return username
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.warning(f"Failed to get GitHub username: {e}")
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _store_git_credentials(url: str, token: str):
|
|
390
|
+
"""
|
|
391
|
+
Store GitHub token in git credential helper.
|
|
392
|
+
|
|
393
|
+
This allows git to authenticate without prompting.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
import subprocess
|
|
397
|
+
|
|
398
|
+
# Configure credential helper
|
|
399
|
+
subprocess.run(
|
|
400
|
+
["git", "config", "--global", "credential.helper", "store"],
|
|
401
|
+
check=False
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Extract hostname from URL
|
|
405
|
+
if 'github.com' in url:
|
|
406
|
+
hostname = 'github.com'
|
|
407
|
+
else:
|
|
408
|
+
# Extract from URL
|
|
409
|
+
from urllib.parse import urlparse
|
|
410
|
+
parsed = urlparse(url)
|
|
411
|
+
hostname = parsed.hostname or 'github.com'
|
|
412
|
+
|
|
413
|
+
# Store credentials
|
|
414
|
+
# Format: https://<token>@github.com
|
|
415
|
+
cred_url = f"https://{token}@{hostname}"
|
|
416
|
+
|
|
417
|
+
# Write to credential store
|
|
418
|
+
cred_file = Path.home() / '.git-credentials'
|
|
419
|
+
|
|
420
|
+
# Read existing credentials
|
|
421
|
+
existing_creds = []
|
|
422
|
+
if cred_file.exists():
|
|
423
|
+
existing_creds = cred_file.read_text().strip().split('\n')
|
|
424
|
+
|
|
425
|
+
# Remove existing credentials for this host
|
|
426
|
+
existing_creds = [c for c in existing_creds if hostname not in c]
|
|
427
|
+
|
|
428
|
+
# Add new credentials
|
|
429
|
+
existing_creds.append(cred_url)
|
|
430
|
+
|
|
431
|
+
# Write back
|
|
432
|
+
cred_file.write_text('\n'.join(existing_creds) + '\n')
|
|
433
|
+
cred_file.chmod(0o600) # Secure permissions
|
|
434
|
+
|
|
435
|
+
logger.info("Stored git credentials")
|
|
436
|
+
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.warning(f"Failed to store credentials: {e}")
|
|
439
|
+
# Non-fatal - user can authenticate manually
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _update_sharing_config(project_root: Path, remote_url: Optional[str], enabled: bool, member_name: Optional[str] = None):
|
|
443
|
+
"""
|
|
444
|
+
Update config.yaml with sharing configuration.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
project_root: Path to project root
|
|
448
|
+
remote_url: GitHub remote URL
|
|
449
|
+
enabled: Whether sharing is enabled
|
|
450
|
+
member_name: GitHub username of the member (for joined repositories)
|
|
451
|
+
"""
|
|
452
|
+
from realign import get_realign_dir
|
|
453
|
+
realign_dir = get_realign_dir(project_root)
|
|
454
|
+
config_path = realign_dir / "config.yaml"
|
|
455
|
+
|
|
456
|
+
# Load existing config
|
|
457
|
+
config = {}
|
|
458
|
+
if config_path.exists():
|
|
459
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
460
|
+
config = yaml.safe_load(f) or {}
|
|
461
|
+
|
|
462
|
+
# Update sharing section
|
|
463
|
+
if 'sharing' not in config:
|
|
464
|
+
config['sharing'] = {}
|
|
465
|
+
|
|
466
|
+
config['sharing']['enabled'] = enabled
|
|
467
|
+
|
|
468
|
+
if remote_url:
|
|
469
|
+
config['sharing']['remote_url'] = remote_url
|
|
470
|
+
|
|
471
|
+
# Extract owner from URL
|
|
472
|
+
repo_path = _extract_github_repo(remote_url)
|
|
473
|
+
if repo_path:
|
|
474
|
+
owner = repo_path.split('/')[0]
|
|
475
|
+
config['sharing']['owner'] = owner
|
|
476
|
+
|
|
477
|
+
# Set created_at if not already set
|
|
478
|
+
if 'created_at' not in config['sharing']:
|
|
479
|
+
config['sharing']['created_at'] = datetime.now().isoformat()
|
|
480
|
+
|
|
481
|
+
# Store member name if provided (for joined repositories)
|
|
482
|
+
if member_name:
|
|
483
|
+
config['sharing']['member_name'] = member_name
|
|
484
|
+
config['sharing']['member_branch'] = f"{member_name}/master"
|
|
485
|
+
|
|
486
|
+
# Save config
|
|
487
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
488
|
+
yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)
|
|
489
|
+
|
|
490
|
+
logger.info(f"Updated sharing config: enabled={enabled}, member={member_name}")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def init_share_flow(repo_root: Optional[Path] = None) -> int:
|
|
494
|
+
"""
|
|
495
|
+
Interactive flow for setting up a new shared repository.
|
|
496
|
+
|
|
497
|
+
This is called by `aline init --share`.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Exit code (0 for success, 1 for error)
|
|
504
|
+
"""
|
|
505
|
+
# Get project root
|
|
506
|
+
if repo_root is None:
|
|
507
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
508
|
+
|
|
509
|
+
print("╭─────────────────────────────────────────╮")
|
|
510
|
+
print("│ ReAlign Sharing Setup │")
|
|
511
|
+
print("╰─────────────────────────────────────────╯")
|
|
512
|
+
print()
|
|
513
|
+
|
|
514
|
+
# Initialize ReAlign if not already done
|
|
515
|
+
from .init import init_repository
|
|
516
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
517
|
+
|
|
518
|
+
if not tracker.is_initialized():
|
|
519
|
+
print("Initializing ReAlign...")
|
|
520
|
+
result = init_repository(repo_path=str(repo_root))
|
|
521
|
+
if not result["success"]:
|
|
522
|
+
print("❌ Failed to initialize ReAlign")
|
|
523
|
+
return 1
|
|
524
|
+
print("✓ ReAlign initialized")
|
|
525
|
+
print()
|
|
526
|
+
|
|
527
|
+
# Check if remote already configured
|
|
528
|
+
if tracker.has_remote():
|
|
529
|
+
remote_url = tracker.get_remote_url()
|
|
530
|
+
print(f"⚠️ Remote already configured: {remote_url}")
|
|
531
|
+
print()
|
|
532
|
+
confirm = input("Reconfigure? [y/N]: ").strip().lower()
|
|
533
|
+
if confirm != 'y':
|
|
534
|
+
print("Cancelled")
|
|
535
|
+
return 0
|
|
536
|
+
print()
|
|
537
|
+
|
|
538
|
+
# Step 1: Get repository name
|
|
539
|
+
print("[1/3] Repository Setup")
|
|
540
|
+
print("─────────────────────")
|
|
541
|
+
print()
|
|
542
|
+
print("Choose a repository name for your team's sessions.")
|
|
543
|
+
print("This will be created as a private repository on GitHub.")
|
|
544
|
+
print()
|
|
545
|
+
|
|
546
|
+
# Suggest default name
|
|
547
|
+
suggested_name = repo_root.name + "-realign-sessions"
|
|
548
|
+
repo_name = input(f"Repository name [{suggested_name}]: ").strip()
|
|
549
|
+
if not repo_name:
|
|
550
|
+
repo_name = suggested_name
|
|
551
|
+
|
|
552
|
+
print()
|
|
553
|
+
|
|
554
|
+
# Step 2: Get GitHub username
|
|
555
|
+
print("[2/3] GitHub Account")
|
|
556
|
+
print("────────────────────")
|
|
557
|
+
print()
|
|
558
|
+
github_user = input("GitHub username: ").strip()
|
|
559
|
+
|
|
560
|
+
if not github_user:
|
|
561
|
+
print("❌ GitHub username required")
|
|
562
|
+
return 1
|
|
563
|
+
|
|
564
|
+
print()
|
|
565
|
+
|
|
566
|
+
# Step 3: Get GitHub token
|
|
567
|
+
print("[3/3] Authentication")
|
|
568
|
+
print("────────────────────")
|
|
569
|
+
print()
|
|
570
|
+
print("Create a GitHub Personal Access Token:")
|
|
571
|
+
print(" 1. Go to: https://github.com/settings/tokens/new")
|
|
572
|
+
print(" 2. Name: 'ReAlign Sharing'")
|
|
573
|
+
print(" 3. Scopes: Select 'repo' (full control of private repositories)")
|
|
574
|
+
print(" 4. Generate token and paste below")
|
|
575
|
+
print()
|
|
576
|
+
|
|
577
|
+
# Open browser to token creation page
|
|
578
|
+
open_browser = input("Open token creation page in browser? [Y/n]: ").strip().lower()
|
|
579
|
+
if open_browser != 'n':
|
|
580
|
+
token_url = "https://github.com/settings/tokens/new?description=ReAlign%20Sharing&scopes=repo"
|
|
581
|
+
webbrowser.open(token_url)
|
|
582
|
+
print("✓ Opened in browser")
|
|
583
|
+
print()
|
|
584
|
+
|
|
585
|
+
# Get token securely
|
|
586
|
+
token = getpass.getpass("GitHub Personal Access Token: ").strip()
|
|
587
|
+
|
|
588
|
+
if not token:
|
|
589
|
+
print("❌ Token required")
|
|
590
|
+
return 1
|
|
591
|
+
|
|
592
|
+
print()
|
|
593
|
+
print("Setting up repository...")
|
|
594
|
+
print()
|
|
595
|
+
|
|
596
|
+
# Construct repository path and URL
|
|
597
|
+
repo_path = f"{github_user}/{repo_name}"
|
|
598
|
+
remote_url = f"https://github.com/{repo_path}.git"
|
|
599
|
+
|
|
600
|
+
# Create repository using GitHub API
|
|
601
|
+
try:
|
|
602
|
+
import subprocess
|
|
603
|
+
import json
|
|
604
|
+
|
|
605
|
+
# Use gh CLI if available, otherwise use GitHub API directly
|
|
606
|
+
gh_available = False
|
|
607
|
+
try:
|
|
608
|
+
gh_check = subprocess.run(
|
|
609
|
+
["gh", "--version"],
|
|
610
|
+
capture_output=True,
|
|
611
|
+
check=False
|
|
612
|
+
)
|
|
613
|
+
gh_available = (gh_check.returncode == 0)
|
|
614
|
+
except FileNotFoundError:
|
|
615
|
+
gh_available = False
|
|
616
|
+
|
|
617
|
+
if gh_available:
|
|
618
|
+
# Use gh CLI
|
|
619
|
+
print("Creating repository using GitHub CLI...")
|
|
620
|
+
result = subprocess.run(
|
|
621
|
+
[
|
|
622
|
+
"gh", "repo", "create", repo_path,
|
|
623
|
+
"--private",
|
|
624
|
+
"--description", "ReAlign AI session history"
|
|
625
|
+
],
|
|
626
|
+
env={**os.environ, "GH_TOKEN": token},
|
|
627
|
+
capture_output=True,
|
|
628
|
+
text=True,
|
|
629
|
+
check=False
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if result.returncode != 0:
|
|
633
|
+
# Repository might already exist
|
|
634
|
+
if "already exists" in result.stderr.lower():
|
|
635
|
+
print(f"Repository {repo_path} already exists, using it...")
|
|
636
|
+
else:
|
|
637
|
+
print(f"❌ Failed to create repository: {result.stderr}")
|
|
638
|
+
return 1
|
|
639
|
+
else:
|
|
640
|
+
print(f"✓ Created private repository: {repo_path}")
|
|
641
|
+
|
|
642
|
+
else:
|
|
643
|
+
# Use GitHub API directly
|
|
644
|
+
print("Creating repository using GitHub API...")
|
|
645
|
+
import urllib.request
|
|
646
|
+
|
|
647
|
+
api_url = "https://api.github.com/user/repos"
|
|
648
|
+
data = json.dumps({
|
|
649
|
+
"name": repo_name,
|
|
650
|
+
"private": True,
|
|
651
|
+
"description": "ReAlign AI session history",
|
|
652
|
+
"auto_init": False
|
|
653
|
+
}).encode('utf-8')
|
|
654
|
+
|
|
655
|
+
req = urllib.request.Request(
|
|
656
|
+
api_url,
|
|
657
|
+
data=data,
|
|
658
|
+
headers={
|
|
659
|
+
"Authorization": f"token {token}",
|
|
660
|
+
"Accept": "application/vnd.github.v3+json",
|
|
661
|
+
"Content-Type": "application/json"
|
|
662
|
+
},
|
|
663
|
+
method="POST"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
with urllib.request.urlopen(req) as response:
|
|
668
|
+
result = json.loads(response.read().decode('utf-8'))
|
|
669
|
+
print(f"✓ Created private repository: {repo_path}")
|
|
670
|
+
except urllib.error.HTTPError as e:
|
|
671
|
+
error_body = e.read().decode('utf-8')
|
|
672
|
+
if "already exists" in error_body.lower():
|
|
673
|
+
print(f"Repository {repo_path} already exists, using it...")
|
|
674
|
+
else:
|
|
675
|
+
print(f"❌ Failed to create repository: {error_body}")
|
|
676
|
+
return 1
|
|
677
|
+
|
|
678
|
+
except Exception as e:
|
|
679
|
+
logger.error(f"Failed to create repository: {e}")
|
|
680
|
+
print(f"❌ Failed to create repository: {e}")
|
|
681
|
+
print()
|
|
682
|
+
print("You can create the repository manually:")
|
|
683
|
+
print(f" 1. Go to: https://github.com/new")
|
|
684
|
+
print(f" 2. Name: {repo_name}")
|
|
685
|
+
print(f" 3. Privacy: Private")
|
|
686
|
+
print(f" 4. Then run: aline share configure {repo_path} --token <your-token>")
|
|
687
|
+
return 1
|
|
688
|
+
|
|
689
|
+
# Configure remote
|
|
690
|
+
print()
|
|
691
|
+
print("Configuring remote...")
|
|
692
|
+
|
|
693
|
+
if not tracker.setup_remote(remote_url):
|
|
694
|
+
print("❌ Failed to configure remote")
|
|
695
|
+
return 1
|
|
696
|
+
|
|
697
|
+
# Store credentials
|
|
698
|
+
_store_git_credentials(remote_url, token)
|
|
699
|
+
|
|
700
|
+
print("✓ Remote configured")
|
|
701
|
+
print()
|
|
702
|
+
|
|
703
|
+
# Update config
|
|
704
|
+
_update_sharing_config(repo_root, remote_url, enabled=True)
|
|
705
|
+
|
|
706
|
+
# Push initial commits
|
|
707
|
+
print("Pushing initial commits...")
|
|
708
|
+
push_success = tracker.safe_push()
|
|
709
|
+
|
|
710
|
+
if push_success:
|
|
711
|
+
print("✓ Initial push successful")
|
|
712
|
+
else:
|
|
713
|
+
print("⚠️ Initial push failed (you can try 'aline push' later)")
|
|
714
|
+
|
|
715
|
+
# Show success message
|
|
716
|
+
print()
|
|
717
|
+
print("╭─────────────────────────────────────────╮")
|
|
718
|
+
print("│ ✓ Setup Complete! │")
|
|
719
|
+
print("╰─────────────────────────────────────────╯")
|
|
720
|
+
print()
|
|
721
|
+
print(f"Repository: https://github.com/{repo_path}")
|
|
722
|
+
print()
|
|
723
|
+
print("Next steps:")
|
|
724
|
+
print(" • Push sessions: aline push")
|
|
725
|
+
print(" • Pull updates: aline pull")
|
|
726
|
+
print(" • Sync: aline sync")
|
|
727
|
+
print()
|
|
728
|
+
print("Share with teammates:")
|
|
729
|
+
print(f" aline init --join {repo_path}")
|
|
730
|
+
print()
|
|
731
|
+
|
|
732
|
+
return 0
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def init_join_flow(repo: str, repo_root: Optional[Path] = None) -> int:
|
|
736
|
+
"""
|
|
737
|
+
Interactive flow for joining an existing shared repository.
|
|
738
|
+
|
|
739
|
+
This is called by `aline init --join <repo>`.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
repo: Repository in format 'user/repo'
|
|
743
|
+
repo_root: Path to repository root (uses cwd if not provided)
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Exit code (0 for success, 1 for error)
|
|
747
|
+
"""
|
|
748
|
+
# Get project root
|
|
749
|
+
if repo_root is None:
|
|
750
|
+
repo_root = Path(os.getcwd()).resolve()
|
|
751
|
+
|
|
752
|
+
print("╭─────────────────────────────────────────╮")
|
|
753
|
+
print("│ Join ReAlign Shared Repository │")
|
|
754
|
+
print("╰─────────────────────────────────────────╯")
|
|
755
|
+
print()
|
|
756
|
+
|
|
757
|
+
# Parse repository
|
|
758
|
+
remote_url = _parse_remote_url(repo)
|
|
759
|
+
if not remote_url:
|
|
760
|
+
print("❌ Invalid repository format")
|
|
761
|
+
print()
|
|
762
|
+
print("Expected format: user/repo")
|
|
763
|
+
print(f"Example: aline init --join alice/team-sessions")
|
|
764
|
+
return 1
|
|
765
|
+
|
|
766
|
+
repo_path = _extract_github_repo(remote_url)
|
|
767
|
+
print(f"Repository: https://github.com/{repo_path}")
|
|
768
|
+
print()
|
|
769
|
+
|
|
770
|
+
# Initialize ReAlign if not already done
|
|
771
|
+
from .init import init_repository
|
|
772
|
+
tracker = ReAlignGitTracker(repo_root)
|
|
773
|
+
|
|
774
|
+
if not tracker.is_initialized():
|
|
775
|
+
print("Initializing ReAlign...")
|
|
776
|
+
result = init_repository(repo_path=str(repo_root), for_join=True)
|
|
777
|
+
if not result["success"]:
|
|
778
|
+
print("❌ Failed to initialize ReAlign")
|
|
779
|
+
return 1
|
|
780
|
+
print("✓ ReAlign initialized")
|
|
781
|
+
print()
|
|
782
|
+
|
|
783
|
+
# Check if remote already configured
|
|
784
|
+
if tracker.has_remote():
|
|
785
|
+
existing_remote = tracker.get_remote_url()
|
|
786
|
+
print(f"⚠️ Remote already configured: {existing_remote}")
|
|
787
|
+
print()
|
|
788
|
+
confirm = input("Reconfigure? [y/N]: ").strip().lower()
|
|
789
|
+
if confirm != 'y':
|
|
790
|
+
print("Cancelled")
|
|
791
|
+
return 0
|
|
792
|
+
print()
|
|
793
|
+
|
|
794
|
+
# Verify repository exists
|
|
795
|
+
print("Verifying repository access...")
|
|
796
|
+
import subprocess
|
|
797
|
+
|
|
798
|
+
# Try to check if repo exists using git ls-remote
|
|
799
|
+
check_result = subprocess.run(
|
|
800
|
+
["git", "ls-remote", remote_url],
|
|
801
|
+
capture_output=True,
|
|
802
|
+
text=True,
|
|
803
|
+
check=False
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
if check_result.returncode != 0:
|
|
807
|
+
# Repository might be private, need authentication
|
|
808
|
+
print("Repository requires authentication")
|
|
809
|
+
print()
|
|
810
|
+
else:
|
|
811
|
+
print("✓ Repository found")
|
|
812
|
+
print()
|
|
813
|
+
|
|
814
|
+
# Get GitHub token
|
|
815
|
+
print("Authentication Required")
|
|
816
|
+
print("──────────────────────")
|
|
817
|
+
print()
|
|
818
|
+
print("You need a GitHub Personal Access Token to access this repository.")
|
|
819
|
+
print()
|
|
820
|
+
print("If you don't have a token:")
|
|
821
|
+
print(" 1. Go to: https://github.com/settings/tokens/new")
|
|
822
|
+
print(" 2. Name: 'ReAlign Sharing'")
|
|
823
|
+
print(" 3. Scopes: Select 'repo' (full control of private repositories)")
|
|
824
|
+
print(" 4. Generate token and paste below")
|
|
825
|
+
print()
|
|
826
|
+
|
|
827
|
+
# Open browser to token creation page
|
|
828
|
+
open_browser = input("Open token creation page in browser? [Y/n]: ").strip().lower()
|
|
829
|
+
if open_browser != 'n':
|
|
830
|
+
token_url = "https://github.com/settings/tokens/new?description=ReAlign%20Sharing&scopes=repo"
|
|
831
|
+
webbrowser.open(token_url)
|
|
832
|
+
print("✓ Opened in browser")
|
|
833
|
+
print()
|
|
834
|
+
|
|
835
|
+
# Get token securely
|
|
836
|
+
token = getpass.getpass("GitHub Personal Access Token: ").strip()
|
|
837
|
+
|
|
838
|
+
if not token:
|
|
839
|
+
print("❌ Token required")
|
|
840
|
+
return 1
|
|
841
|
+
|
|
842
|
+
print()
|
|
843
|
+
|
|
844
|
+
# Verify access with token
|
|
845
|
+
print("Verifying access...")
|
|
846
|
+
verify_result = subprocess.run(
|
|
847
|
+
["git", "ls-remote", remote_url],
|
|
848
|
+
env={**os.environ, "GIT_ASKPASS": "echo", "GIT_USERNAME": "x-access-token", "GIT_PASSWORD": token},
|
|
849
|
+
capture_output=True,
|
|
850
|
+
text=True,
|
|
851
|
+
check=False
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if verify_result.returncode != 0:
|
|
855
|
+
print("❌ Failed to access repository")
|
|
856
|
+
print()
|
|
857
|
+
print("Possible issues:")
|
|
858
|
+
print(" • Invalid token")
|
|
859
|
+
print(" • No access to repository")
|
|
860
|
+
print(" • Repository doesn't exist")
|
|
861
|
+
print()
|
|
862
|
+
print("Ask the repository owner to invite you:")
|
|
863
|
+
print(f" GitHub Settings → {repo_path} → Manage Access → Invite")
|
|
864
|
+
return 1
|
|
865
|
+
|
|
866
|
+
print("✓ Access verified")
|
|
867
|
+
print()
|
|
868
|
+
|
|
869
|
+
# Get GitHub username from token
|
|
870
|
+
print("Getting GitHub username...")
|
|
871
|
+
github_username = _get_github_username(token)
|
|
872
|
+
if not github_username:
|
|
873
|
+
print("⚠️ Could not retrieve GitHub username, using current git user")
|
|
874
|
+
# Fallback: try to get from git config
|
|
875
|
+
import subprocess
|
|
876
|
+
result = subprocess.run(
|
|
877
|
+
["git", "config", "user.name"],
|
|
878
|
+
capture_output=True,
|
|
879
|
+
text=True,
|
|
880
|
+
check=False
|
|
881
|
+
)
|
|
882
|
+
github_username = result.stdout.strip() if result.returncode == 0 else None
|
|
883
|
+
|
|
884
|
+
if not github_username:
|
|
885
|
+
print("❌ Could not determine username for branch")
|
|
886
|
+
return 1
|
|
887
|
+
|
|
888
|
+
member_branch = f"{github_username}/master"
|
|
889
|
+
print(f"✓ Member branch: {member_branch}")
|
|
890
|
+
print()
|
|
891
|
+
|
|
892
|
+
# Configure remote
|
|
893
|
+
print("Configuring remote...")
|
|
894
|
+
|
|
895
|
+
if not tracker.setup_remote(remote_url):
|
|
896
|
+
print("❌ Failed to configure remote")
|
|
897
|
+
return 1
|
|
898
|
+
|
|
899
|
+
# Store credentials
|
|
900
|
+
_store_git_credentials(remote_url, token)
|
|
901
|
+
|
|
902
|
+
print("✓ Remote configured")
|
|
903
|
+
print()
|
|
904
|
+
|
|
905
|
+
# Pull existing sessions FIRST (before updating config)
|
|
906
|
+
# This ensures we get the remote's config.yaml and .gitignore
|
|
907
|
+
print("Pulling existing sessions from owner's master branch...")
|
|
908
|
+
pull_success = tracker.safe_pull()
|
|
909
|
+
|
|
910
|
+
if pull_success:
|
|
911
|
+
print("✓ Pulled from owner's master branch")
|
|
912
|
+
print()
|
|
913
|
+
|
|
914
|
+
# Create member-specific branch based on owner's master
|
|
915
|
+
print(f"Creating member branch: {member_branch}...")
|
|
916
|
+
if not tracker.create_branch(member_branch, start_point="master"):
|
|
917
|
+
print("❌ Failed to create member branch")
|
|
918
|
+
return 1
|
|
919
|
+
print(f"✓ Created and checked out branch: {member_branch}")
|
|
920
|
+
print()
|
|
921
|
+
else:
|
|
922
|
+
print("⚠️ Failed to pull from owner's master (repository might be empty)")
|
|
923
|
+
print("Creating member branch on empty repository...")
|
|
924
|
+
if not tracker.create_branch(member_branch, start_point="master"):
|
|
925
|
+
# If master doesn't exist, try creating a branch directly
|
|
926
|
+
print("⚠️ Could not create from master, attempting to create as initial branch...")
|
|
927
|
+
# This might happen on a completely empty repo
|
|
928
|
+
pass
|
|
929
|
+
print()
|
|
930
|
+
|
|
931
|
+
# Update config AFTER pull
|
|
932
|
+
# This way we preserve any existing config from remote
|
|
933
|
+
_update_sharing_config(repo_root, remote_url, enabled=True, member_name=github_username)
|
|
934
|
+
|
|
935
|
+
if pull_success:
|
|
936
|
+
# Get commit count
|
|
937
|
+
import subprocess
|
|
938
|
+
result = subprocess.run(
|
|
939
|
+
["git", "rev-list", "--count", "HEAD"],
|
|
940
|
+
cwd=tracker.realign_dir,
|
|
941
|
+
capture_output=True,
|
|
942
|
+
text=True,
|
|
943
|
+
check=False
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
commit_count = result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
947
|
+
print(f"✓ Pulled {commit_count} commit(s)")
|
|
948
|
+
else:
|
|
949
|
+
print("⚠️ Pull failed (repository might be empty)")
|
|
950
|
+
|
|
951
|
+
# Show success message
|
|
952
|
+
print()
|
|
953
|
+
print("╭─────────────────────────────────────────╮")
|
|
954
|
+
print("│ ✓ Successfully Joined! │")
|
|
955
|
+
print("╰─────────────────────────────────────────╯")
|
|
956
|
+
print()
|
|
957
|
+
print(f"Repository: https://github.com/{repo_path}")
|
|
958
|
+
print()
|
|
959
|
+
print("You can now:")
|
|
960
|
+
print(" • Push sessions: aline push")
|
|
961
|
+
print(" • Pull updates: aline pull")
|
|
962
|
+
print(" • Sync: aline sync")
|
|
963
|
+
print()
|
|
964
|
+
|
|
965
|
+
return 0
|