aline-ai 0.2.3__py3-none-any.whl → 0.2.5__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.3.dist-info → aline_ai-0.2.5.dist-info}/METADATA +2 -1
- {aline_ai-0.2.3.dist-info → aline_ai-0.2.5.dist-info}/RECORD +14 -11
- realign/__init__.py +1 -1
- realign/cli.py +47 -1
- realign/commands/__init__.py +4 -0
- realign/commands/hide.py +1024 -0
- realign/commands/push.py +290 -0
- realign/commands/review.py +492 -0
- realign/mcp_server.py +133 -0
- realign/redactor.py +48 -10
- {aline_ai-0.2.3.dist-info → aline_ai-0.2.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.3.dist-info → aline_ai-0.2.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.2.3.dist-info → aline_ai-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.3.dist-info → aline_ai-0.2.5.dist-info}/top_level.txt +0 -0
realign/commands/hide.py
ADDED
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hide command - Redact sensitive commits before pushing.
|
|
4
|
+
|
|
5
|
+
This allows users to hide (redact) specific commits by rewriting git history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Tuple, Optional, Set
|
|
15
|
+
|
|
16
|
+
from .review import get_unpushed_commits, UnpushedCommit
|
|
17
|
+
from ..logging_config import setup_logger
|
|
18
|
+
|
|
19
|
+
logger = setup_logger('realign.commands.hide', 'hide.log')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_commit_indices(indices_str: str) -> List[int]:
|
|
23
|
+
"""
|
|
24
|
+
Parse user input of commit indices.
|
|
25
|
+
|
|
26
|
+
Supports:
|
|
27
|
+
- Single: "3" -> [3]
|
|
28
|
+
- Multiple: "1,3,5" -> [1, 3, 5]
|
|
29
|
+
- Range: "2-4" -> [2, 3, 4]
|
|
30
|
+
- Combined: "1,3,5-7" -> [1, 3, 5, 6, 7]
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
indices_str: User input string
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Sorted list of unique indices
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If input format is invalid
|
|
40
|
+
"""
|
|
41
|
+
if not indices_str or not indices_str.strip():
|
|
42
|
+
raise ValueError("Empty input")
|
|
43
|
+
|
|
44
|
+
result: Set[int] = set()
|
|
45
|
+
|
|
46
|
+
for part in indices_str.split(','):
|
|
47
|
+
part = part.strip()
|
|
48
|
+
|
|
49
|
+
if not part:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if '-' in part:
|
|
53
|
+
# Range: "2-4"
|
|
54
|
+
range_parts = part.split('-', 1)
|
|
55
|
+
if len(range_parts) != 2:
|
|
56
|
+
raise ValueError(f"Invalid range format: {part}")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
start = int(range_parts[0].strip())
|
|
60
|
+
end = int(range_parts[1].strip())
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise ValueError(f"Invalid range format: {part}")
|
|
63
|
+
|
|
64
|
+
if start > end:
|
|
65
|
+
raise ValueError(f"Invalid range (start > end): {part}")
|
|
66
|
+
|
|
67
|
+
result.update(range(start, end + 1))
|
|
68
|
+
else:
|
|
69
|
+
# Single number
|
|
70
|
+
try:
|
|
71
|
+
num = int(part)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise ValueError(f"Invalid number: {part}")
|
|
74
|
+
|
|
75
|
+
if num < 1:
|
|
76
|
+
raise ValueError(f"Index must be >= 1: {num}")
|
|
77
|
+
|
|
78
|
+
result.add(num)
|
|
79
|
+
|
|
80
|
+
return sorted(result)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def perform_safety_checks(repo_root: Path) -> Tuple[bool, str]:
|
|
84
|
+
"""
|
|
85
|
+
Perform safety checks before rewriting git history.
|
|
86
|
+
|
|
87
|
+
Checks:
|
|
88
|
+
1. Working directory is clean
|
|
89
|
+
2. Not in detached HEAD state
|
|
90
|
+
3. Has unpushed commits
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
repo_root: Path to repository root
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tuple of (success, message)
|
|
97
|
+
"""
|
|
98
|
+
logger.info("Performing safety checks")
|
|
99
|
+
|
|
100
|
+
# 1. Check working directory is clean
|
|
101
|
+
status_result = subprocess.run(
|
|
102
|
+
["git", "status", "--porcelain"],
|
|
103
|
+
cwd=repo_root,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if status_result.stdout.strip():
|
|
109
|
+
return False, "Working directory is not clean. Please commit or stash your changes first."
|
|
110
|
+
|
|
111
|
+
# 2. Check not in detached HEAD
|
|
112
|
+
branch_result = subprocess.run(
|
|
113
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
114
|
+
cwd=repo_root,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
branch = branch_result.stdout.strip()
|
|
120
|
+
if branch == "HEAD":
|
|
121
|
+
return False, "You are in detached HEAD state. Please checkout a branch first."
|
|
122
|
+
|
|
123
|
+
logger.info(f"Current branch: {branch}")
|
|
124
|
+
|
|
125
|
+
# 3. Check has unpushed commits (will be checked by caller)
|
|
126
|
+
|
|
127
|
+
logger.info("Safety checks passed")
|
|
128
|
+
return True, "OK"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def confirm_hide_operation(
|
|
132
|
+
commits_to_hide: List[UnpushedCommit],
|
|
133
|
+
all_commits: List[UnpushedCommit]
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Show warning and ask user for confirmation.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
commits_to_hide: Commits to be hidden
|
|
140
|
+
all_commits: All unpushed commits
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if user confirms, False otherwise
|
|
144
|
+
"""
|
|
145
|
+
print("\n⚠️ WARNING: This will rewrite git history!\n")
|
|
146
|
+
print("What will happen:")
|
|
147
|
+
print(" • Commit messages will be redacted")
|
|
148
|
+
print(" • Session content added in these commits will be redacted")
|
|
149
|
+
print(" • All later commits will be rebased\n")
|
|
150
|
+
|
|
151
|
+
print(f"Commits to hide ({len(commits_to_hide)}):")
|
|
152
|
+
for commit in commits_to_hide:
|
|
153
|
+
print(f" [{commit.index}] {commit.hash} - {commit.message}")
|
|
154
|
+
|
|
155
|
+
print()
|
|
156
|
+
|
|
157
|
+
# Calculate affected commits
|
|
158
|
+
# Index 1 is HEAD (newest), higher index = older commits
|
|
159
|
+
# We need to rewrite from HEAD (index 1) down to the oldest commit being hidden
|
|
160
|
+
# For example: hide index 3 means rewrite commits 1, 2, 3 (3 commits total)
|
|
161
|
+
max_hide_index = max(c.index for c in commits_to_hide)
|
|
162
|
+
affected_count = max_hide_index # All commits from 1 to max_hide_index
|
|
163
|
+
|
|
164
|
+
print(f"⚠️ This will rewrite {affected_count} commit(s) in total.\n")
|
|
165
|
+
|
|
166
|
+
response = input("Proceed? [y/N] ").strip().lower()
|
|
167
|
+
|
|
168
|
+
return response == 'y'
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def redact_session_lines(
|
|
172
|
+
session_file: Path,
|
|
173
|
+
line_ranges: List[Tuple[int, int]]
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Redact specific line ranges in a session file.
|
|
177
|
+
|
|
178
|
+
Strategy C: Preserve JSON structure, clear content
|
|
179
|
+
|
|
180
|
+
Result format:
|
|
181
|
+
{
|
|
182
|
+
"type": "user", // Preserved
|
|
183
|
+
"message": {"content": "[REDACTED]"}, // Cleared
|
|
184
|
+
"redacted": true,
|
|
185
|
+
"redacted_at": "2025-11-22T19:30:00"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
session_file: Path to session file
|
|
190
|
+
line_ranges: List of (start_line, end_line) tuples (1-based, inclusive)
|
|
191
|
+
"""
|
|
192
|
+
logger.info(f"Redacting session file: {session_file}")
|
|
193
|
+
logger.debug(f"Line ranges to redact: {line_ranges}")
|
|
194
|
+
|
|
195
|
+
if not session_file.exists():
|
|
196
|
+
logger.warning(f"Session file not found: {session_file}")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Read file
|
|
200
|
+
with open(session_file, 'r', encoding='utf-8') as f:
|
|
201
|
+
lines = f.readlines()
|
|
202
|
+
|
|
203
|
+
# Collect all line indices to redact (convert to 0-based)
|
|
204
|
+
lines_to_redact: Set[int] = set()
|
|
205
|
+
for start, end in line_ranges:
|
|
206
|
+
lines_to_redact.update(range(start - 1, end)) # Convert to 0-based
|
|
207
|
+
|
|
208
|
+
logger.debug(f"Total lines to redact: {len(lines_to_redact)}")
|
|
209
|
+
|
|
210
|
+
# Redact lines
|
|
211
|
+
redacted_count = 0
|
|
212
|
+
for i in lines_to_redact:
|
|
213
|
+
if i >= len(lines):
|
|
214
|
+
logger.warning(f"Line index {i} out of range (file has {len(lines)} lines)")
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Try to preserve JSON structure
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(lines[i])
|
|
220
|
+
|
|
221
|
+
# Create redacted object preserving type
|
|
222
|
+
redacted = {
|
|
223
|
+
"type": data.get("type", "redacted"),
|
|
224
|
+
"message": {"content": "[REDACTED]"},
|
|
225
|
+
"redacted": True,
|
|
226
|
+
"redacted_at": datetime.now().isoformat()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
lines[i] = json.dumps(redacted, ensure_ascii=False) + '\n'
|
|
230
|
+
redacted_count += 1
|
|
231
|
+
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
# If not valid JSON, replace entire line
|
|
234
|
+
logger.debug(f"Line {i+1} is not valid JSON, replacing entire line")
|
|
235
|
+
lines[i] = '{"type": "redacted", "content": "[REDACTED]"}\n'
|
|
236
|
+
redacted_count += 1
|
|
237
|
+
|
|
238
|
+
logger.info(f"Redacted {redacted_count} line(s) in {session_file}")
|
|
239
|
+
|
|
240
|
+
# Write back
|
|
241
|
+
with open(session_file, 'w', encoding='utf-8') as f:
|
|
242
|
+
f.writelines(lines)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def redact_commit_message(original_message: str) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Redact commit message.
|
|
248
|
+
|
|
249
|
+
Original format:
|
|
250
|
+
chore: Auto-commit MCP session (2025-11-22 19:24:29)
|
|
251
|
+
|
|
252
|
+
--- LLM-Summary (claude-3-5-haiku) ---
|
|
253
|
+
* [Claude] Discussed database credentials and API keys
|
|
254
|
+
|
|
255
|
+
Agent-Redacted: false
|
|
256
|
+
|
|
257
|
+
Redacted format:
|
|
258
|
+
chore: Auto-commit MCP session (2025-11-22 19:24:29) [REDACTED]
|
|
259
|
+
|
|
260
|
+
--- LLM-Summary (claude-3-5-haiku) ---
|
|
261
|
+
* [Claude] [REDACTED - Content hidden by user on 2025-11-22T19:30:00]
|
|
262
|
+
|
|
263
|
+
Agent-Redacted: true
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
original_message: Original commit message
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Redacted commit message
|
|
270
|
+
"""
|
|
271
|
+
lines = original_message.split('\n')
|
|
272
|
+
redacted_lines = []
|
|
273
|
+
in_summary = False
|
|
274
|
+
|
|
275
|
+
timestamp = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
|
|
276
|
+
|
|
277
|
+
for line in lines:
|
|
278
|
+
# First line: add [REDACTED] marker
|
|
279
|
+
if not redacted_lines:
|
|
280
|
+
redacted_lines.append(line.rstrip() + " [REDACTED]")
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# LLM Summary section
|
|
284
|
+
if '--- LLM-Summary' in line:
|
|
285
|
+
in_summary = True
|
|
286
|
+
redacted_lines.append(line)
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if in_summary:
|
|
290
|
+
if line.strip().startswith('*'):
|
|
291
|
+
# Extract agent prefix: "* [Claude] Text" -> "* [Claude]"
|
|
292
|
+
if ']' in line:
|
|
293
|
+
prefix = line.split(']')[0] + ']'
|
|
294
|
+
else:
|
|
295
|
+
prefix = '*'
|
|
296
|
+
|
|
297
|
+
redacted_lines.append(
|
|
298
|
+
f"{prefix} [REDACTED - Content hidden by user on {timestamp}]"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
elif line.strip().startswith('---') or line.strip().startswith('Agent-'):
|
|
302
|
+
# End of summary section
|
|
303
|
+
in_summary = False
|
|
304
|
+
redacted_lines.append(line)
|
|
305
|
+
|
|
306
|
+
else:
|
|
307
|
+
redacted_lines.append(line)
|
|
308
|
+
|
|
309
|
+
else:
|
|
310
|
+
# Update Agent-Redacted flag
|
|
311
|
+
if line.strip().startswith('Agent-Redacted:'):
|
|
312
|
+
redacted_lines.append('Agent-Redacted: true')
|
|
313
|
+
else:
|
|
314
|
+
redacted_lines.append(line)
|
|
315
|
+
|
|
316
|
+
return '\n'.join(redacted_lines)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def create_backup_ref(repo_root: Path) -> str:
|
|
320
|
+
"""
|
|
321
|
+
Create a backup reference before rewriting history.
|
|
322
|
+
|
|
323
|
+
This allows users to recover if something goes wrong.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
repo_root: Path to repository root
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Backup reference name (e.g., "refs/realign/backup_20251122_193000")
|
|
330
|
+
"""
|
|
331
|
+
logger.info("Creating backup reference")
|
|
332
|
+
|
|
333
|
+
# Get current commit
|
|
334
|
+
current_commit = subprocess.run(
|
|
335
|
+
["git", "rev-parse", "HEAD"],
|
|
336
|
+
cwd=repo_root,
|
|
337
|
+
capture_output=True,
|
|
338
|
+
text=True,
|
|
339
|
+
check=True
|
|
340
|
+
).stdout.strip()
|
|
341
|
+
|
|
342
|
+
# Create backup ref
|
|
343
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
344
|
+
backup_ref = f"refs/realign/backup_{timestamp}"
|
|
345
|
+
|
|
346
|
+
subprocess.run(
|
|
347
|
+
["git", "update-ref", backup_ref, current_commit],
|
|
348
|
+
cwd=repo_root,
|
|
349
|
+
check=True
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
logger.info(f"Created backup reference: {backup_ref}")
|
|
353
|
+
print(f"✓ Created backup: {backup_ref}")
|
|
354
|
+
print(f" If something goes wrong, you can restore with:")
|
|
355
|
+
print(f" git reset --hard {backup_ref}\n")
|
|
356
|
+
|
|
357
|
+
return backup_ref
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def hide_commits_with_filter_repo(
|
|
361
|
+
commits_to_hide: List[UnpushedCommit],
|
|
362
|
+
all_commits: List[UnpushedCommit],
|
|
363
|
+
repo_root: Path
|
|
364
|
+
) -> tuple[bool, dict, str]:
|
|
365
|
+
"""
|
|
366
|
+
Hide commits using git-filter-repo.
|
|
367
|
+
|
|
368
|
+
This rewrites git history to redact commit messages and session content.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
commits_to_hide: Commits to hide
|
|
372
|
+
all_commits: All unpushed commits
|
|
373
|
+
repo_root: Path to repository root
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Tuple of (success: bool, content_to_redact: dict, redacted_timestamp: str)
|
|
377
|
+
"""
|
|
378
|
+
logger.info(f"Hiding {len(commits_to_hide)} commit(s) using git-filter-repo")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
# Import git-filter-repo
|
|
382
|
+
try:
|
|
383
|
+
import git_filter_repo as fr
|
|
384
|
+
except ImportError:
|
|
385
|
+
print("\nError: git-filter-repo is not installed.", file=sys.stderr)
|
|
386
|
+
print("Please install it with: pip install git-filter-repo\n", file=sys.stderr)
|
|
387
|
+
logger.error("git-filter-repo not installed")
|
|
388
|
+
return False, {}, ""
|
|
389
|
+
|
|
390
|
+
# Build a map of commits to hide
|
|
391
|
+
commits_to_hide_hashes = {c.full_hash for c in commits_to_hide}
|
|
392
|
+
|
|
393
|
+
# Build a map of session files to redact for each commit
|
|
394
|
+
redact_map = {} # {commit_hash: {session_file: [(start, end), ...]}}
|
|
395
|
+
for commit in commits_to_hide:
|
|
396
|
+
redact_map[commit.full_hash] = commit.session_additions
|
|
397
|
+
|
|
398
|
+
logger.debug(f"Redact map: {redact_map}")
|
|
399
|
+
|
|
400
|
+
# Create callback to modify commits
|
|
401
|
+
def commit_callback(commit, metadata):
|
|
402
|
+
"""Callback to modify each commit."""
|
|
403
|
+
commit_hash = commit.original_id.decode('utf-8')
|
|
404
|
+
|
|
405
|
+
if commit_hash in commits_to_hide_hashes:
|
|
406
|
+
logger.debug(f"Processing commit to hide: {commit_hash[:8]}")
|
|
407
|
+
|
|
408
|
+
# Redact commit message
|
|
409
|
+
original_message = commit.message.decode('utf-8')
|
|
410
|
+
redacted_message = redact_commit_message(original_message)
|
|
411
|
+
commit.message = redacted_message.encode('utf-8')
|
|
412
|
+
|
|
413
|
+
logger.debug(f"Redacted commit message for {commit_hash[:8]}")
|
|
414
|
+
|
|
415
|
+
def blob_callback(blob, metadata):
|
|
416
|
+
"""Callback to modify file contents."""
|
|
417
|
+
# Only process session files
|
|
418
|
+
if not blob.data:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
# Get current commit being processed
|
|
422
|
+
# Note: git-filter-repo doesn't easily expose this,
|
|
423
|
+
# so we use a different approach below
|
|
424
|
+
|
|
425
|
+
# Actually, git-filter-repo's Python API is quite complex for this use case.
|
|
426
|
+
# A simpler approach is to use git commands directly.
|
|
427
|
+
logger.warning("git-filter-repo approach is complex, switching to manual git rebase")
|
|
428
|
+
|
|
429
|
+
success, content_to_redact, redacted_timestamp = hide_commits_manual(commits_to_hide, all_commits, repo_root)
|
|
430
|
+
return success, content_to_redact, redacted_timestamp
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error(f"Error hiding commits: {e}", exc_info=True)
|
|
434
|
+
print(f"\nError: {e}\n", file=sys.stderr)
|
|
435
|
+
return False, {}, ""
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def hide_commits_manual(
|
|
439
|
+
commits_to_hide: List[UnpushedCommit],
|
|
440
|
+
all_commits: List[UnpushedCommit],
|
|
441
|
+
repo_root: Path
|
|
442
|
+
) -> bool:
|
|
443
|
+
"""
|
|
444
|
+
Hide commits using git filter-branch.
|
|
445
|
+
|
|
446
|
+
Strategy:
|
|
447
|
+
1. Collect all original content to redact from the commits being hidden
|
|
448
|
+
2. In tree-filter, search and replace that content in ALL commits
|
|
449
|
+
3. In msg-filter, only redact messages for commits being hidden
|
|
450
|
+
|
|
451
|
+
This works because session files are append-only: content added in commit A
|
|
452
|
+
appears at the same location in all subsequent commits.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
commits_to_hide: Commits to hide
|
|
456
|
+
all_commits: All unpushed commits
|
|
457
|
+
repo_root: Path to repository root
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Tuple of (success: bool, content_to_redact: dict, redacted_timestamp: str)
|
|
461
|
+
"""
|
|
462
|
+
logger.info("Hiding commits using git filter-branch")
|
|
463
|
+
|
|
464
|
+
# Build commit hash set
|
|
465
|
+
commits_to_hide_hashes = {c.full_hash for c in commits_to_hide}
|
|
466
|
+
|
|
467
|
+
# Collect all content to redact: {session_file: [original_line_content, ...]}
|
|
468
|
+
# We need to get the ORIGINAL content from the repository
|
|
469
|
+
content_to_redact = {}
|
|
470
|
+
for commit in commits_to_hide:
|
|
471
|
+
for session_file, line_ranges in commit.session_additions.items():
|
|
472
|
+
if session_file not in content_to_redact:
|
|
473
|
+
content_to_redact[session_file] = []
|
|
474
|
+
|
|
475
|
+
# Read the file at this commit to get original content
|
|
476
|
+
for start_line, end_line in line_ranges:
|
|
477
|
+
# Get file content at this commit
|
|
478
|
+
file_content_result = subprocess.run(
|
|
479
|
+
["git", "show", f"{commit.full_hash}:{session_file}"],
|
|
480
|
+
cwd=repo_root,
|
|
481
|
+
capture_output=True,
|
|
482
|
+
text=True
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if file_content_result.returncode == 0:
|
|
486
|
+
lines = file_content_result.stdout.split('\n')
|
|
487
|
+
# Collect the actual lines (1-based indexing)
|
|
488
|
+
for line_num in range(start_line, end_line + 1):
|
|
489
|
+
if line_num <= len(lines):
|
|
490
|
+
original_line = lines[line_num - 1]
|
|
491
|
+
if not original_line:
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
# Skip lines that are already redacted
|
|
495
|
+
# First check the raw string for redacted content
|
|
496
|
+
if "[REDACTED:" in original_line or "[REDACTED]" in original_line:
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
data = json.loads(original_line)
|
|
501
|
+
# Skip if line has redacted flag
|
|
502
|
+
if data.get("redacted") == True:
|
|
503
|
+
continue
|
|
504
|
+
except json.JSONDecodeError:
|
|
505
|
+
# If we can't parse it, skip it (likely already corrupted/redacted)
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# Collect unique lines
|
|
509
|
+
if original_line not in content_to_redact[session_file]:
|
|
510
|
+
content_to_redact[session_file].append(original_line)
|
|
511
|
+
|
|
512
|
+
logger.debug(f"Commits to hide: {list(commits_to_hide_hashes)}")
|
|
513
|
+
logger.debug(f"Content to redact: {content_to_redact}")
|
|
514
|
+
|
|
515
|
+
# Find the range to rewrite
|
|
516
|
+
# Index 1 is HEAD (newest), higher index = older commits
|
|
517
|
+
# We need to rewrite from the oldest hidden commit's parent to HEAD
|
|
518
|
+
oldest_commit = max(commits_to_hide, key=lambda c: c.index)
|
|
519
|
+
|
|
520
|
+
# Get parent of oldest commit being hidden
|
|
521
|
+
parent_result = subprocess.run(
|
|
522
|
+
["git", "rev-parse", f"{oldest_commit.full_hash}^"],
|
|
523
|
+
cwd=repo_root,
|
|
524
|
+
capture_output=True,
|
|
525
|
+
text=True
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if parent_result.returncode == 0:
|
|
529
|
+
parent_hash = parent_result.stdout.strip()
|
|
530
|
+
commit_range = f"{parent_hash}..HEAD"
|
|
531
|
+
else:
|
|
532
|
+
# No parent (first commit), rewrite entire history
|
|
533
|
+
commit_range = "HEAD"
|
|
534
|
+
|
|
535
|
+
logger.info(f"Rewriting commit range: {commit_range}")
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
# Create a script file for the msg-filter
|
|
539
|
+
msg_filter_script = repo_root / ".git" / "realign_msg_filter.py"
|
|
540
|
+
# Use regular string (not f-string) to avoid escaping issues
|
|
541
|
+
msg_filter_code = '''#!/usr/bin/env python3
|
|
542
|
+
import sys
|
|
543
|
+
import os
|
|
544
|
+
|
|
545
|
+
# Read commit hash from environment
|
|
546
|
+
commit_hash = os.getenv("GIT_COMMIT")
|
|
547
|
+
|
|
548
|
+
# Commits to redact
|
|
549
|
+
commits_to_redact = ''' + repr(list(commits_to_hide_hashes)) + '''
|
|
550
|
+
|
|
551
|
+
# Read original message from stdin
|
|
552
|
+
original_message = sys.stdin.read()
|
|
553
|
+
|
|
554
|
+
# CRITICAL FIX: Check if message is already redacted
|
|
555
|
+
# This prevents un-redacting previously hidden commits
|
|
556
|
+
first_line = original_message.split('\\n')[0] if original_message else ''
|
|
557
|
+
if '[REDACTED]' in first_line:
|
|
558
|
+
# Already redacted, preserve as-is
|
|
559
|
+
print(original_message, end='')
|
|
560
|
+
elif commit_hash in commits_to_redact:
|
|
561
|
+
# Redact message
|
|
562
|
+
from datetime import datetime
|
|
563
|
+
lines = original_message.split('\\n')
|
|
564
|
+
redacted_lines = []
|
|
565
|
+
in_summary = False
|
|
566
|
+
|
|
567
|
+
timestamp = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
|
|
568
|
+
|
|
569
|
+
for line in lines:
|
|
570
|
+
if not redacted_lines:
|
|
571
|
+
redacted_lines.append(line.rstrip() + " [REDACTED]")
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
if '--- LLM-Summary' in line:
|
|
575
|
+
in_summary = True
|
|
576
|
+
redacted_lines.append(line)
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
if in_summary:
|
|
580
|
+
if line.strip().startswith('*'):
|
|
581
|
+
if ']' in line:
|
|
582
|
+
prefix = line.split(']')[0] + ']'
|
|
583
|
+
else:
|
|
584
|
+
prefix = '*'
|
|
585
|
+
redacted_lines.append(f"{prefix} [REDACTED - Content hidden by user on {timestamp}]")
|
|
586
|
+
elif line.strip().startswith('---') or line.strip().startswith('Agent-'):
|
|
587
|
+
in_summary = False
|
|
588
|
+
redacted_lines.append(line)
|
|
589
|
+
else:
|
|
590
|
+
redacted_lines.append(line)
|
|
591
|
+
else:
|
|
592
|
+
if line.strip().startswith('Agent-Redacted:'):
|
|
593
|
+
redacted_lines.append('Agent-Redacted: true')
|
|
594
|
+
else:
|
|
595
|
+
redacted_lines.append(line)
|
|
596
|
+
|
|
597
|
+
print('\\n'.join(redacted_lines))
|
|
598
|
+
else:
|
|
599
|
+
print(original_message, end='')
|
|
600
|
+
'''
|
|
601
|
+
msg_filter_script.write_text(msg_filter_code, encoding='utf-8')
|
|
602
|
+
|
|
603
|
+
msg_filter_script.chmod(0o755)
|
|
604
|
+
|
|
605
|
+
# Create a script for the tree-filter
|
|
606
|
+
tree_filter_script = repo_root / ".git" / "realign_tree_filter.py"
|
|
607
|
+
|
|
608
|
+
# Use a fixed timestamp for all redactions in this hide operation
|
|
609
|
+
redacted_timestamp = datetime.now().isoformat()
|
|
610
|
+
|
|
611
|
+
# Use regular string (not f-string) to avoid escaping issues
|
|
612
|
+
tree_filter_code = '''#!/usr/bin/env python3
|
|
613
|
+
import sys
|
|
614
|
+
import os
|
|
615
|
+
import json
|
|
616
|
+
from pathlib import Path
|
|
617
|
+
|
|
618
|
+
# Fixed timestamp for this hide operation
|
|
619
|
+
REDACTED_TIMESTAMP = ''' + repr(redacted_timestamp) + '''
|
|
620
|
+
|
|
621
|
+
# Content to redact: {session_file: [original_line_content, ...]}
|
|
622
|
+
content_to_redact = ''' + json.dumps(content_to_redact, ensure_ascii=False) + '''
|
|
623
|
+
|
|
624
|
+
# Redacted replacement
|
|
625
|
+
def create_redacted_line(original_line):
|
|
626
|
+
"""Create a redacted version of a line, preserving structure if possible."""
|
|
627
|
+
try:
|
|
628
|
+
data = json.loads(original_line)
|
|
629
|
+
return json.dumps({
|
|
630
|
+
"type": data.get("type", "redacted"),
|
|
631
|
+
"message": {"content": "[REDACTED]"},
|
|
632
|
+
"redacted": True,
|
|
633
|
+
"redacted_at": REDACTED_TIMESTAMP
|
|
634
|
+
}, ensure_ascii=False)
|
|
635
|
+
except json.JSONDecodeError:
|
|
636
|
+
return json.dumps({
|
|
637
|
+
"type": "redacted",
|
|
638
|
+
"message": {"content": "[REDACTED]"},
|
|
639
|
+
"redacted": True,
|
|
640
|
+
"redacted_at": REDACTED_TIMESTAMP
|
|
641
|
+
}, ensure_ascii=False)
|
|
642
|
+
|
|
643
|
+
# Process each session file
|
|
644
|
+
for session_file, original_lines in content_to_redact.items():
|
|
645
|
+
file_path = Path(session_file)
|
|
646
|
+
|
|
647
|
+
if not file_path.exists():
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
# Read file
|
|
651
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
652
|
+
content = f.read()
|
|
653
|
+
|
|
654
|
+
# Replace each original line with redacted version
|
|
655
|
+
modified = False
|
|
656
|
+
for original_line in original_lines:
|
|
657
|
+
if original_line in content:
|
|
658
|
+
redacted_line = create_redacted_line(original_line)
|
|
659
|
+
content = content.replace(original_line, redacted_line)
|
|
660
|
+
modified = True
|
|
661
|
+
|
|
662
|
+
# Write back if modified
|
|
663
|
+
if modified:
|
|
664
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
665
|
+
f.write(content)
|
|
666
|
+
'''
|
|
667
|
+
tree_filter_script.write_text(tree_filter_code, encoding='utf-8')
|
|
668
|
+
|
|
669
|
+
tree_filter_script.chmod(0o755)
|
|
670
|
+
|
|
671
|
+
# Run git filter-branch
|
|
672
|
+
print(f"🔄 Rewriting git history...")
|
|
673
|
+
print(f" This may take a while...\n")
|
|
674
|
+
|
|
675
|
+
filter_result = subprocess.run(
|
|
676
|
+
[
|
|
677
|
+
"git", "filter-branch",
|
|
678
|
+
"--force",
|
|
679
|
+
"--tree-filter", f"python3 {tree_filter_script}",
|
|
680
|
+
"--msg-filter", f"python3 {msg_filter_script}",
|
|
681
|
+
"--tag-name-filter", "cat",
|
|
682
|
+
"--", commit_range
|
|
683
|
+
],
|
|
684
|
+
cwd=repo_root,
|
|
685
|
+
capture_output=True,
|
|
686
|
+
text=True,
|
|
687
|
+
env={**os.environ, "FILTER_BRANCH_SQUELCH_WARNING": "1"}
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Clean up scripts
|
|
691
|
+
msg_filter_script.unlink(missing_ok=True)
|
|
692
|
+
tree_filter_script.unlink(missing_ok=True)
|
|
693
|
+
|
|
694
|
+
if filter_result.returncode != 0:
|
|
695
|
+
logger.error(f"git filter-branch failed: {filter_result.stderr}")
|
|
696
|
+
print(f"\n❌ Error during git filter-branch: {filter_result.stderr}\n", file=sys.stderr)
|
|
697
|
+
return False, {}, ""
|
|
698
|
+
|
|
699
|
+
print("\n✅ Successfully redacted commit(s)")
|
|
700
|
+
logger.info("Successfully redacted commits")
|
|
701
|
+
|
|
702
|
+
# Clean up backup refs created by filter-branch
|
|
703
|
+
print("\n🧹 Cleaning up...")
|
|
704
|
+
subprocess.run(
|
|
705
|
+
["git", "for-each-ref", "--format=%(refname)", "refs/original/"],
|
|
706
|
+
cwd=repo_root,
|
|
707
|
+
capture_output=True
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return True, content_to_redact, redacted_timestamp
|
|
711
|
+
|
|
712
|
+
except Exception as e:
|
|
713
|
+
logger.error(f"Unexpected error: {e}", exc_info=True)
|
|
714
|
+
print(f"\n❌ Unexpected error: {e}\n", file=sys.stderr)
|
|
715
|
+
return False, {}, ""
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def apply_redaction_to_working_dir(
|
|
719
|
+
content_to_redact: dict,
|
|
720
|
+
redacted_timestamp: str,
|
|
721
|
+
repo_root: Path
|
|
722
|
+
) -> bool:
|
|
723
|
+
"""
|
|
724
|
+
Apply redaction to session files in the working directory.
|
|
725
|
+
|
|
726
|
+
This ensures that future auto-commits will include the redacted content,
|
|
727
|
+
not the original sensitive information.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
content_to_redact: Dict mapping session files to lists of original lines to redact
|
|
731
|
+
redacted_timestamp: ISO timestamp for the redaction
|
|
732
|
+
repo_root: Path to repository root
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
True on success, False on failure
|
|
736
|
+
"""
|
|
737
|
+
logger.info("Applying redaction to working directory session files")
|
|
738
|
+
|
|
739
|
+
def create_redacted_line(original_line):
|
|
740
|
+
"""Create a redacted version of a session line."""
|
|
741
|
+
try:
|
|
742
|
+
data = json.loads(original_line)
|
|
743
|
+
return json.dumps({
|
|
744
|
+
"type": data.get("type", "redacted"),
|
|
745
|
+
"message": {"content": "[REDACTED]"},
|
|
746
|
+
"redacted": True,
|
|
747
|
+
"redacted_at": redacted_timestamp
|
|
748
|
+
}, ensure_ascii=False)
|
|
749
|
+
except json.JSONDecodeError:
|
|
750
|
+
return json.dumps({
|
|
751
|
+
"type": "redacted",
|
|
752
|
+
"message": {"content": "[REDACTED]"},
|
|
753
|
+
"redacted": True,
|
|
754
|
+
"redacted_at": redacted_timestamp
|
|
755
|
+
}, ensure_ascii=False)
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
for session_file, original_lines in content_to_redact.items():
|
|
759
|
+
file_path = repo_root / session_file
|
|
760
|
+
|
|
761
|
+
if not file_path.exists():
|
|
762
|
+
logger.warning(f"Session file not found in working directory: {session_file}")
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
# Read current content
|
|
766
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
767
|
+
content = f.read()
|
|
768
|
+
|
|
769
|
+
# Apply redactions
|
|
770
|
+
modified = False
|
|
771
|
+
for original_line in original_lines:
|
|
772
|
+
if original_line in content:
|
|
773
|
+
redacted_line = create_redacted_line(original_line)
|
|
774
|
+
content = content.replace(original_line, redacted_line)
|
|
775
|
+
modified = True
|
|
776
|
+
logger.debug(f"Redacted line in {session_file}")
|
|
777
|
+
|
|
778
|
+
# Write back if modified
|
|
779
|
+
if modified:
|
|
780
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
781
|
+
f.write(content)
|
|
782
|
+
logger.info(f"Applied redaction to {session_file}")
|
|
783
|
+
|
|
784
|
+
# Also update the backup file in sessions-original if it exists
|
|
785
|
+
# This ensures pre-commit hook will use the redacted version
|
|
786
|
+
backup_file = repo_root / ".realign" / "sessions-original" / Path(session_file).name
|
|
787
|
+
if backup_file.exists():
|
|
788
|
+
with open(backup_file, 'w', encoding='utf-8') as f:
|
|
789
|
+
f.write(content)
|
|
790
|
+
logger.info(f"Applied redaction to backup {backup_file.name}")
|
|
791
|
+
|
|
792
|
+
print("✅ Applied redaction to working directory")
|
|
793
|
+
return True
|
|
794
|
+
|
|
795
|
+
except Exception as e:
|
|
796
|
+
logger.error(f"Failed to apply redaction to working directory: {e}", exc_info=True)
|
|
797
|
+
print(f"⚠️ Warning: Failed to apply redaction to working directory: {e}", file=sys.stderr)
|
|
798
|
+
return False
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def hide_command(
|
|
802
|
+
indices: str,
|
|
803
|
+
repo_root: Optional[Path] = None,
|
|
804
|
+
force: bool = False
|
|
805
|
+
) -> int:
|
|
806
|
+
"""
|
|
807
|
+
Main entry point for hide command.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
indices: Commit indices to hide (e.g., "1,3,5-7")
|
|
811
|
+
repo_root: Path to repository root (auto-detected if None)
|
|
812
|
+
force: Skip confirmation prompt
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
0 on success, 1 on error
|
|
816
|
+
"""
|
|
817
|
+
logger.info(f"======== Hide command started: indices={indices} ========")
|
|
818
|
+
|
|
819
|
+
# Auto-detect repo root if not provided
|
|
820
|
+
if repo_root is None:
|
|
821
|
+
try:
|
|
822
|
+
result = subprocess.run(
|
|
823
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
824
|
+
capture_output=True,
|
|
825
|
+
text=True,
|
|
826
|
+
check=True
|
|
827
|
+
)
|
|
828
|
+
repo_root = Path(result.stdout.strip())
|
|
829
|
+
logger.debug(f"Detected repo root: {repo_root}")
|
|
830
|
+
except subprocess.CalledProcessError:
|
|
831
|
+
print("Error: Not in a git repository", file=sys.stderr)
|
|
832
|
+
logger.error("Not in a git repository")
|
|
833
|
+
return 1
|
|
834
|
+
|
|
835
|
+
# Perform safety checks
|
|
836
|
+
safe, message = perform_safety_checks(repo_root)
|
|
837
|
+
if not safe:
|
|
838
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
839
|
+
logger.error(f"Safety check failed: {message}")
|
|
840
|
+
return 1
|
|
841
|
+
|
|
842
|
+
# Get all unpushed commits
|
|
843
|
+
try:
|
|
844
|
+
all_commits = get_unpushed_commits(repo_root)
|
|
845
|
+
except Exception as e:
|
|
846
|
+
print(f"Error: Failed to get unpushed commits: {e}", file=sys.stderr)
|
|
847
|
+
logger.error(f"Failed to get unpushed commits: {e}", exc_info=True)
|
|
848
|
+
return 1
|
|
849
|
+
|
|
850
|
+
if not all_commits:
|
|
851
|
+
print("Error: No unpushed commits found", file=sys.stderr)
|
|
852
|
+
logger.error("No unpushed commits found")
|
|
853
|
+
return 1
|
|
854
|
+
|
|
855
|
+
# Parse indices
|
|
856
|
+
try:
|
|
857
|
+
if indices == "--all":
|
|
858
|
+
indices_list = [c.index for c in all_commits]
|
|
859
|
+
else:
|
|
860
|
+
indices_list = parse_commit_indices(indices)
|
|
861
|
+
except ValueError as e:
|
|
862
|
+
print(f"Error: Invalid indices format: {e}", file=sys.stderr)
|
|
863
|
+
logger.error(f"Invalid indices format: {e}")
|
|
864
|
+
return 1
|
|
865
|
+
|
|
866
|
+
# Validate indices
|
|
867
|
+
max_index = len(all_commits)
|
|
868
|
+
invalid_indices = [i for i in indices_list if i < 1 or i > max_index]
|
|
869
|
+
if invalid_indices:
|
|
870
|
+
print(f"Error: Invalid indices (out of range 1-{max_index}): {invalid_indices}", file=sys.stderr)
|
|
871
|
+
logger.error(f"Invalid indices: {invalid_indices}")
|
|
872
|
+
return 1
|
|
873
|
+
|
|
874
|
+
# Get commits to hide
|
|
875
|
+
commits_to_hide = [c for c in all_commits if c.index in indices_list]
|
|
876
|
+
|
|
877
|
+
logger.info(f"Commits to hide: {[c.hash for c in commits_to_hide]}")
|
|
878
|
+
|
|
879
|
+
# Confirm operation
|
|
880
|
+
if not force:
|
|
881
|
+
if not confirm_hide_operation(commits_to_hide, all_commits):
|
|
882
|
+
print("Operation cancelled by user")
|
|
883
|
+
logger.info("Operation cancelled by user")
|
|
884
|
+
return 0
|
|
885
|
+
|
|
886
|
+
# Create backup
|
|
887
|
+
try:
|
|
888
|
+
create_backup_ref(repo_root)
|
|
889
|
+
except Exception as e:
|
|
890
|
+
print(f"Warning: Failed to create backup: {e}", file=sys.stderr)
|
|
891
|
+
logger.warning(f"Failed to create backup: {e}")
|
|
892
|
+
|
|
893
|
+
# Hide commits
|
|
894
|
+
success, content_to_redact, redacted_timestamp = hide_commits_with_filter_repo(commits_to_hide, all_commits, repo_root)
|
|
895
|
+
|
|
896
|
+
if success:
|
|
897
|
+
# Apply redaction to working directory session files
|
|
898
|
+
# This ensures future auto-commits will include redacted content
|
|
899
|
+
apply_redaction_to_working_dir(content_to_redact, redacted_timestamp, repo_root)
|
|
900
|
+
|
|
901
|
+
logger.info("======== Hide command completed successfully ========")
|
|
902
|
+
return 0
|
|
903
|
+
else:
|
|
904
|
+
print("\n❌ Failed to hide commits. Check the backup reference if needed.\n", file=sys.stderr)
|
|
905
|
+
logger.error("======== Hide command failed ========")
|
|
906
|
+
return 1
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def hide_reset_command(
|
|
910
|
+
repo_root: Optional[Path] = None,
|
|
911
|
+
force: bool = False
|
|
912
|
+
) -> int:
|
|
913
|
+
"""
|
|
914
|
+
Reset to the last backup before hide operation.
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
repo_root: Path to repository root (auto-detected if None)
|
|
918
|
+
force: Skip confirmation prompt
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
0 on success, 1 on error
|
|
922
|
+
"""
|
|
923
|
+
logger.info("======== Hide reset command started ========")
|
|
924
|
+
|
|
925
|
+
# Auto-detect repo root if not provided
|
|
926
|
+
if repo_root is None:
|
|
927
|
+
try:
|
|
928
|
+
result = subprocess.run(
|
|
929
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
930
|
+
capture_output=True,
|
|
931
|
+
text=True,
|
|
932
|
+
check=True
|
|
933
|
+
)
|
|
934
|
+
repo_root = Path(result.stdout.strip())
|
|
935
|
+
logger.debug(f"Detected repo root: {repo_root}")
|
|
936
|
+
except subprocess.CalledProcessError:
|
|
937
|
+
print("Error: Not in a git repository", file=sys.stderr)
|
|
938
|
+
logger.error("Not in a git repository")
|
|
939
|
+
return 1
|
|
940
|
+
|
|
941
|
+
# Find all backup refs
|
|
942
|
+
result = subprocess.run(
|
|
943
|
+
["git", "for-each-ref", "refs/realign/", "--format=%(refname) %(objectname) %(creatordate:unix)", "--sort=-creatordate"],
|
|
944
|
+
cwd=repo_root,
|
|
945
|
+
capture_output=True,
|
|
946
|
+
text=True
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
if result.returncode != 0:
|
|
950
|
+
print("Error: Failed to list backup references", file=sys.stderr)
|
|
951
|
+
logger.error("Failed to list backup references")
|
|
952
|
+
return 1
|
|
953
|
+
|
|
954
|
+
# Parse backup refs
|
|
955
|
+
backup_refs = []
|
|
956
|
+
for line in result.stdout.strip().split('\n'):
|
|
957
|
+
if not line or not line.startswith('refs/realign/backup_'):
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
parts = line.split()
|
|
961
|
+
if len(parts) >= 3:
|
|
962
|
+
ref_name = parts[0]
|
|
963
|
+
commit_hash = parts[1]
|
|
964
|
+
timestamp = int(parts[2])
|
|
965
|
+
backup_refs.append((ref_name, commit_hash, timestamp))
|
|
966
|
+
|
|
967
|
+
if not backup_refs:
|
|
968
|
+
print("Error: No backup references found", file=sys.stderr)
|
|
969
|
+
print("You can only reset after running 'aline hide'", file=sys.stderr)
|
|
970
|
+
logger.error("No backup references found")
|
|
971
|
+
return 1
|
|
972
|
+
|
|
973
|
+
# Get the most recent backup
|
|
974
|
+
latest_backup = backup_refs[0]
|
|
975
|
+
backup_ref, backup_commit, _ = latest_backup
|
|
976
|
+
|
|
977
|
+
# Get current commit
|
|
978
|
+
current_result = subprocess.run(
|
|
979
|
+
["git", "rev-parse", "HEAD"],
|
|
980
|
+
cwd=repo_root,
|
|
981
|
+
capture_output=True,
|
|
982
|
+
text=True,
|
|
983
|
+
check=True
|
|
984
|
+
)
|
|
985
|
+
current_commit = current_result.stdout.strip()
|
|
986
|
+
|
|
987
|
+
# Check if we're already at the backup
|
|
988
|
+
if current_commit == backup_commit:
|
|
989
|
+
print(f"Already at backup commit: {backup_commit[:8]}")
|
|
990
|
+
print("Nothing to reset.")
|
|
991
|
+
return 0
|
|
992
|
+
|
|
993
|
+
# Show what will happen
|
|
994
|
+
print(f"\n🔄 Reset to last hide backup\n")
|
|
995
|
+
print(f"Current HEAD: {current_commit[:8]}")
|
|
996
|
+
print(f"Backup ref: {backup_ref}")
|
|
997
|
+
print(f"Backup HEAD: {backup_commit[:8]}\n")
|
|
998
|
+
|
|
999
|
+
if not force:
|
|
1000
|
+
response = input("Proceed with reset? [y/N] ").strip().lower()
|
|
1001
|
+
if response != 'y':
|
|
1002
|
+
print("Reset cancelled")
|
|
1003
|
+
logger.info("Reset cancelled by user")
|
|
1004
|
+
return 0
|
|
1005
|
+
|
|
1006
|
+
# Perform the reset
|
|
1007
|
+
try:
|
|
1008
|
+
reset_result = subprocess.run(
|
|
1009
|
+
["git", "reset", "--hard", backup_ref],
|
|
1010
|
+
cwd=repo_root,
|
|
1011
|
+
capture_output=True,
|
|
1012
|
+
text=True,
|
|
1013
|
+
check=True
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
print(f"\n✅ Successfully reset to {backup_commit[:8]}")
|
|
1017
|
+
print(f" {reset_result.stdout.strip()}")
|
|
1018
|
+
logger.info(f"Successfully reset to {backup_ref}")
|
|
1019
|
+
return 0
|
|
1020
|
+
|
|
1021
|
+
except subprocess.CalledProcessError as e:
|
|
1022
|
+
print(f"\n❌ Failed to reset: {e.stderr}", file=sys.stderr)
|
|
1023
|
+
logger.error(f"Failed to reset: {e.stderr}")
|
|
1024
|
+
return 1
|