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,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)
|