aline-ai 0.2.3__py3-none-any.whl → 0.2.4__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.
@@ -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