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
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Review command - Display unpushed commits with session summaries.
|
|
4
|
+
|
|
5
|
+
This allows users to review what will be pushed before making it public.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Dict, Tuple, Optional
|
|
15
|
+
|
|
16
|
+
from ..logging_config import setup_logger
|
|
17
|
+
|
|
18
|
+
logger = setup_logger('realign.commands.review', 'review.log')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class UnpushedCommit:
|
|
23
|
+
"""Represents an unpushed commit with session information."""
|
|
24
|
+
index: int # User-visible index (1-based)
|
|
25
|
+
hash: str # Short commit hash
|
|
26
|
+
full_hash: str # Full commit hash
|
|
27
|
+
message: str # First line of commit message
|
|
28
|
+
timestamp: datetime # Commit timestamp
|
|
29
|
+
llm_summary: str # Extracted LLM summary
|
|
30
|
+
session_files: List[str] # Session files modified
|
|
31
|
+
session_additions: Dict[str, List[Tuple[int, int]]] # {file: [(start, end), ...]}
|
|
32
|
+
has_sensitive: bool = False # Whether sensitive content detected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
|
|
36
|
+
"""
|
|
37
|
+
Get all unpushed commits from the current branch.
|
|
38
|
+
|
|
39
|
+
Strategy (方案 C):
|
|
40
|
+
1. Try to use upstream branch (@{u})
|
|
41
|
+
2. Fallback to origin/main or origin/master
|
|
42
|
+
3. If no remote exists, show all commits on current branch
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
repo_root: Path to repository root
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of UnpushedCommit objects, ordered from newest to oldest
|
|
49
|
+
"""
|
|
50
|
+
logger.info("Getting unpushed commits")
|
|
51
|
+
|
|
52
|
+
# Try to get upstream branch
|
|
53
|
+
upstream_result = subprocess.run(
|
|
54
|
+
["git", "rev-parse", "--abbrev-ref", "@{u}"],
|
|
55
|
+
cwd=repo_root,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if upstream_result.returncode == 0:
|
|
61
|
+
base = "@{u}"
|
|
62
|
+
logger.debug(f"Using upstream branch: {upstream_result.stdout.strip()}")
|
|
63
|
+
else:
|
|
64
|
+
# Fallback to origin/main or origin/master
|
|
65
|
+
main_branch = detect_main_branch(repo_root)
|
|
66
|
+
base = f"origin/{main_branch}"
|
|
67
|
+
logger.debug(f"No upstream found, using fallback: {base}")
|
|
68
|
+
|
|
69
|
+
# Verify that the remote branch exists
|
|
70
|
+
verify_result = subprocess.run(
|
|
71
|
+
["git", "rev-parse", "--verify", base],
|
|
72
|
+
cwd=repo_root,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if verify_result.returncode != 0:
|
|
78
|
+
# Remote branch doesn't exist, show all commits on current branch
|
|
79
|
+
base = None
|
|
80
|
+
logger.info("No remote branch found, will show all commits on current branch")
|
|
81
|
+
|
|
82
|
+
# Get commit list
|
|
83
|
+
# Format: full_hash|short_hash|subject|timestamp
|
|
84
|
+
if base:
|
|
85
|
+
log_cmd = ["git", "log", f"{base}..HEAD", "--format=%H|%h|%s|%at"]
|
|
86
|
+
else:
|
|
87
|
+
# No remote, show all commits
|
|
88
|
+
log_cmd = ["git", "log", "HEAD", "--format=%H|%h|%s|%at"]
|
|
89
|
+
|
|
90
|
+
log_result = subprocess.run(
|
|
91
|
+
log_cmd,
|
|
92
|
+
cwd=repo_root,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if log_result.returncode != 0:
|
|
98
|
+
logger.error(f"Failed to get commit list: {log_result.stderr}")
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
commit_lines = [line for line in log_result.stdout.strip().split('\n') if line]
|
|
102
|
+
|
|
103
|
+
if not commit_lines:
|
|
104
|
+
logger.info("No unpushed commits found")
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
logger.info(f"Found {len(commit_lines)} unpushed commit(s)")
|
|
108
|
+
|
|
109
|
+
# Parse commits
|
|
110
|
+
commits = []
|
|
111
|
+
for idx, line in enumerate(commit_lines, 1):
|
|
112
|
+
parts = line.split('|')
|
|
113
|
+
if len(parts) != 4:
|
|
114
|
+
logger.warning(f"Skipping malformed commit line: {line}")
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
full_hash, short_hash, subject, timestamp_str = parts
|
|
118
|
+
timestamp = datetime.fromtimestamp(int(timestamp_str))
|
|
119
|
+
|
|
120
|
+
# Get full commit message
|
|
121
|
+
full_message = subprocess.run(
|
|
122
|
+
["git", "log", "-1", "--format=%B", full_hash],
|
|
123
|
+
cwd=repo_root,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True
|
|
126
|
+
).stdout
|
|
127
|
+
|
|
128
|
+
# Extract LLM summary
|
|
129
|
+
llm_summary = extract_llm_summary(full_message)
|
|
130
|
+
|
|
131
|
+
# Get session file additions
|
|
132
|
+
session_files, session_additions = get_session_additions(full_hash, repo_root)
|
|
133
|
+
|
|
134
|
+
commit = UnpushedCommit(
|
|
135
|
+
index=idx,
|
|
136
|
+
hash=short_hash,
|
|
137
|
+
full_hash=full_hash,
|
|
138
|
+
message=subject,
|
|
139
|
+
timestamp=timestamp,
|
|
140
|
+
llm_summary=llm_summary,
|
|
141
|
+
session_files=session_files,
|
|
142
|
+
session_additions=session_additions,
|
|
143
|
+
has_sensitive=False # Will be set by --detect-secrets flag
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
commits.append(commit)
|
|
147
|
+
logger.debug(f"Parsed commit [{idx}] {short_hash}: {subject}")
|
|
148
|
+
|
|
149
|
+
return commits
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def detect_main_branch(repo_root: Path) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Detect the main branch name (main or master).
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
repo_root: Path to repository root
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
"main" or "master"
|
|
161
|
+
"""
|
|
162
|
+
# Check if origin/main exists
|
|
163
|
+
main_check = subprocess.run(
|
|
164
|
+
["git", "rev-parse", "--verify", "origin/main"],
|
|
165
|
+
cwd=repo_root,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if main_check.returncode == 0:
|
|
171
|
+
return "main"
|
|
172
|
+
|
|
173
|
+
# Check if origin/master exists
|
|
174
|
+
master_check = subprocess.run(
|
|
175
|
+
["git", "rev-parse", "--verify", "origin/master"],
|
|
176
|
+
cwd=repo_root,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if master_check.returncode == 0:
|
|
182
|
+
return "master"
|
|
183
|
+
|
|
184
|
+
# If no remote branches exist, check current local branch name
|
|
185
|
+
current_branch = subprocess.run(
|
|
186
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
187
|
+
cwd=repo_root,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if current_branch.returncode == 0:
|
|
193
|
+
branch_name = current_branch.stdout.strip()
|
|
194
|
+
logger.info(f"No remote found, using current branch: {branch_name}")
|
|
195
|
+
return branch_name
|
|
196
|
+
|
|
197
|
+
# Default to main
|
|
198
|
+
logger.warning("Could not detect main branch, defaulting to 'main'")
|
|
199
|
+
return "main"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def extract_llm_summary(commit_message: str) -> str:
|
|
203
|
+
"""
|
|
204
|
+
Extract LLM summary from commit message.
|
|
205
|
+
|
|
206
|
+
Expected format:
|
|
207
|
+
chore: Auto-commit MCP session (2025-11-22 19:24:29)
|
|
208
|
+
|
|
209
|
+
--- LLM-Summary (claude-3-5-haiku) ---
|
|
210
|
+
* [Claude] Discussed implementing JWT authentication
|
|
211
|
+
* [Codex] Fixed bug in payment module
|
|
212
|
+
|
|
213
|
+
Agent-Redacted: false
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
commit_message: Full commit message
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Extracted summary text (without * and [Agent] prefix), or "(No summary)"
|
|
220
|
+
"""
|
|
221
|
+
lines = commit_message.split('\n')
|
|
222
|
+
|
|
223
|
+
in_summary = False
|
|
224
|
+
summary_lines = []
|
|
225
|
+
|
|
226
|
+
for line in lines:
|
|
227
|
+
# Start of summary section
|
|
228
|
+
if '--- LLM-Summary' in line:
|
|
229
|
+
in_summary = True
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if in_summary:
|
|
233
|
+
# End of summary section
|
|
234
|
+
if line.strip().startswith('---') or line.strip().startswith('Agent-'):
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# Extract summary content
|
|
238
|
+
if line.strip().startswith('*'):
|
|
239
|
+
# Remove leading "* "
|
|
240
|
+
content = line.strip()[1:].strip()
|
|
241
|
+
|
|
242
|
+
# Remove [Agent] prefix if present
|
|
243
|
+
if ']' in content:
|
|
244
|
+
# "* [Claude] Text here" -> "Text here"
|
|
245
|
+
content = content.split(']', 1)[1].strip()
|
|
246
|
+
|
|
247
|
+
summary_lines.append(content)
|
|
248
|
+
|
|
249
|
+
if summary_lines:
|
|
250
|
+
return ' | '.join(summary_lines)
|
|
251
|
+
else:
|
|
252
|
+
return "(No summary)"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_session_additions(commit_hash: str, repo_root: Path) -> Tuple[List[str], Dict[str, List[Tuple[int, int]]]]:
|
|
256
|
+
"""
|
|
257
|
+
Get session files modified in this commit and their line additions.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
commit_hash: Commit hash
|
|
261
|
+
repo_root: Path to repository root
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Tuple of:
|
|
265
|
+
- List of session file paths (relative to repo root)
|
|
266
|
+
- Dict mapping file paths to line ranges: {file: [(start, end), ...]}
|
|
267
|
+
"""
|
|
268
|
+
logger.debug(f"Getting session additions for commit {commit_hash}")
|
|
269
|
+
|
|
270
|
+
# Get files modified in this commit
|
|
271
|
+
files_result = subprocess.run(
|
|
272
|
+
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
|
|
273
|
+
cwd=repo_root,
|
|
274
|
+
capture_output=True,
|
|
275
|
+
text=True
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if files_result.returncode != 0:
|
|
279
|
+
logger.warning(f"Failed to get files for commit {commit_hash}")
|
|
280
|
+
return [], {}
|
|
281
|
+
|
|
282
|
+
all_files = files_result.stdout.strip().split('\n')
|
|
283
|
+
|
|
284
|
+
# Filter session files
|
|
285
|
+
session_files = [
|
|
286
|
+
f for f in all_files
|
|
287
|
+
if f.startswith('.realign/sessions/') and f.endswith('.jsonl')
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
if not session_files:
|
|
291
|
+
logger.debug(f"No session files in commit {commit_hash}")
|
|
292
|
+
return [], {}
|
|
293
|
+
|
|
294
|
+
logger.debug(f"Found {len(session_files)} session file(s) in commit {commit_hash}")
|
|
295
|
+
|
|
296
|
+
# Get line additions for each session file
|
|
297
|
+
additions = {}
|
|
298
|
+
|
|
299
|
+
for session_file in session_files:
|
|
300
|
+
# Get diff for this file
|
|
301
|
+
diff_result = subprocess.run(
|
|
302
|
+
["git", "show", commit_hash, "--", session_file],
|
|
303
|
+
cwd=repo_root,
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if diff_result.returncode != 0:
|
|
309
|
+
logger.warning(f"Failed to get diff for {session_file}")
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Parse diff to extract line ranges
|
|
313
|
+
line_ranges = parse_diff_additions(diff_result.stdout)
|
|
314
|
+
|
|
315
|
+
if line_ranges:
|
|
316
|
+
additions[session_file] = line_ranges
|
|
317
|
+
total_lines = sum(end - start + 1 for start, end in line_ranges)
|
|
318
|
+
logger.debug(f" {session_file}: +{total_lines} lines in {len(line_ranges)} range(s)")
|
|
319
|
+
|
|
320
|
+
return session_files, additions
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_diff_additions(diff_output: str) -> List[Tuple[int, int]]:
|
|
324
|
+
"""
|
|
325
|
+
Parse git diff output to extract line ranges of additions.
|
|
326
|
+
|
|
327
|
+
Diff format:
|
|
328
|
+
@@ -10,5 +10,8 @@
|
|
329
|
+
existing line
|
|
330
|
+
+new line 1
|
|
331
|
+
+new line 2
|
|
332
|
+
+new line 3
|
|
333
|
+
existing line
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
diff_output: Output from git show or git diff
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
List of (start_line, end_line) tuples (1-based, inclusive)
|
|
340
|
+
Line numbers are based on the NEW file (after commit)
|
|
341
|
+
"""
|
|
342
|
+
ranges = []
|
|
343
|
+
current_line = 0
|
|
344
|
+
range_start = None
|
|
345
|
+
|
|
346
|
+
for line in diff_output.split('\n'):
|
|
347
|
+
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
|
|
348
|
+
if line.startswith('@@'):
|
|
349
|
+
match = re.search(r'\+(\d+),?(\d+)?', line)
|
|
350
|
+
if match:
|
|
351
|
+
current_line = int(match.group(1))
|
|
352
|
+
range_start = None
|
|
353
|
+
logger.debug(f" Hunk starts at line {current_line}")
|
|
354
|
+
|
|
355
|
+
# Added line
|
|
356
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
357
|
+
if range_start is None:
|
|
358
|
+
range_start = current_line
|
|
359
|
+
current_line += 1
|
|
360
|
+
|
|
361
|
+
# Context line (unchanged)
|
|
362
|
+
elif line.startswith(' '):
|
|
363
|
+
if range_start is not None:
|
|
364
|
+
# End current range
|
|
365
|
+
ranges.append((range_start, current_line - 1))
|
|
366
|
+
logger.debug(f" Range: {range_start}-{current_line - 1}")
|
|
367
|
+
range_start = None
|
|
368
|
+
current_line += 1
|
|
369
|
+
|
|
370
|
+
# Deleted line (doesn't affect new file line numbers)
|
|
371
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
# Handle last range if still open
|
|
375
|
+
if range_start is not None:
|
|
376
|
+
ranges.append((range_start, current_line - 1))
|
|
377
|
+
logger.debug(f" Range: {range_start}-{current_line - 1}")
|
|
378
|
+
|
|
379
|
+
return ranges
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def display_unpushed_commits(commits: List[UnpushedCommit], verbose: bool = False):
|
|
383
|
+
"""
|
|
384
|
+
Display list of unpushed commits in a user-friendly format.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
commits: List of UnpushedCommit objects
|
|
388
|
+
verbose: Whether to show detailed information
|
|
389
|
+
"""
|
|
390
|
+
if not commits:
|
|
391
|
+
print("\n✓ No unpushed commits found.\n")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
print(f"\n📋 Unpushed commits ({len(commits)}):\n")
|
|
395
|
+
|
|
396
|
+
for commit in commits:
|
|
397
|
+
# Basic info
|
|
398
|
+
print(f" [{commit.index}] {commit.hash} - {commit.message}")
|
|
399
|
+
print(f" Time: {commit.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
400
|
+
|
|
401
|
+
# LLM Summary
|
|
402
|
+
if commit.llm_summary and commit.llm_summary != "(No summary)":
|
|
403
|
+
print(f" Summary: {commit.llm_summary}")
|
|
404
|
+
|
|
405
|
+
# Session files
|
|
406
|
+
if commit.session_files:
|
|
407
|
+
for session_file in commit.session_files:
|
|
408
|
+
additions = commit.session_additions.get(session_file, [])
|
|
409
|
+
total_lines = sum(end - start + 1 for start, end in additions)
|
|
410
|
+
print(f" Session: {session_file} (+{total_lines} lines)")
|
|
411
|
+
|
|
412
|
+
# Sensitive content warning
|
|
413
|
+
if commit.has_sensitive:
|
|
414
|
+
print(f" ⚠️ WARNING: Potential sensitive content detected")
|
|
415
|
+
|
|
416
|
+
print() # Blank line separator
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def review_command(
|
|
420
|
+
repo_root: Optional[Path] = None,
|
|
421
|
+
verbose: bool = False,
|
|
422
|
+
detect_secrets: bool = False
|
|
423
|
+
) -> int:
|
|
424
|
+
"""
|
|
425
|
+
Main entry point for review command.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
repo_root: Path to repository root (auto-detected if None)
|
|
429
|
+
verbose: Show detailed information
|
|
430
|
+
detect_secrets: Run sensitive content detection
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
0 on success, 1 on error
|
|
434
|
+
"""
|
|
435
|
+
logger.info("======== Review command started ========")
|
|
436
|
+
|
|
437
|
+
# Auto-detect repo root if not provided
|
|
438
|
+
if repo_root is None:
|
|
439
|
+
try:
|
|
440
|
+
result = subprocess.run(
|
|
441
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
442
|
+
capture_output=True,
|
|
443
|
+
text=True,
|
|
444
|
+
check=True
|
|
445
|
+
)
|
|
446
|
+
repo_root = Path(result.stdout.strip())
|
|
447
|
+
logger.debug(f"Detected repo root: {repo_root}")
|
|
448
|
+
except subprocess.CalledProcessError:
|
|
449
|
+
print("Error: Not in a git repository", file=sys.stderr)
|
|
450
|
+
logger.error("Not in a git repository")
|
|
451
|
+
return 1
|
|
452
|
+
|
|
453
|
+
# Get unpushed commits
|
|
454
|
+
try:
|
|
455
|
+
commits = get_unpushed_commits(repo_root)
|
|
456
|
+
except Exception as e:
|
|
457
|
+
print(f"Error: Failed to get unpushed commits: {e}", file=sys.stderr)
|
|
458
|
+
logger.error(f"Failed to get unpushed commits: {e}", exc_info=True)
|
|
459
|
+
return 1
|
|
460
|
+
|
|
461
|
+
# Detect sensitive content if requested
|
|
462
|
+
if detect_secrets:
|
|
463
|
+
try:
|
|
464
|
+
from ..redactor import check_and_redact_session
|
|
465
|
+
|
|
466
|
+
for commit in commits:
|
|
467
|
+
for session_file in commit.session_files:
|
|
468
|
+
file_path = repo_root / session_file
|
|
469
|
+
if not file_path.exists():
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
473
|
+
content = f.read()
|
|
474
|
+
|
|
475
|
+
_, has_secrets, _ = check_and_redact_session(
|
|
476
|
+
content,
|
|
477
|
+
redact_mode="auto"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if has_secrets:
|
|
481
|
+
commit.has_sensitive = True
|
|
482
|
+
logger.warning(f"Detected sensitive content in commit {commit.hash}")
|
|
483
|
+
break
|
|
484
|
+
except ImportError:
|
|
485
|
+
print("Warning: detect-secrets not available, skipping sensitive content detection", file=sys.stderr)
|
|
486
|
+
logger.warning("detect-secrets not available")
|
|
487
|
+
|
|
488
|
+
# Display commits
|
|
489
|
+
display_unpushed_commits(commits, verbose=verbose)
|
|
490
|
+
|
|
491
|
+
logger.info("======== Review command completed ========")
|
|
492
|
+
return 0
|
realign/mcp_server.py
CHANGED
|
@@ -170,6 +170,63 @@ async def list_tools() -> list[Tool]:
|
|
|
170
170
|
"properties": {},
|
|
171
171
|
},
|
|
172
172
|
),
|
|
173
|
+
Tool(
|
|
174
|
+
name="aline_review",
|
|
175
|
+
description=(
|
|
176
|
+
"Review unpushed commits before pushing. "
|
|
177
|
+
"Shows commit messages, LLM summaries, session files modified, and line counts. "
|
|
178
|
+
"Helps identify what content will be made public when you push."
|
|
179
|
+
),
|
|
180
|
+
inputSchema={
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"repo_path": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"description": "Path to repository root (default: current directory)",
|
|
186
|
+
},
|
|
187
|
+
"verbose": {
|
|
188
|
+
"type": "boolean",
|
|
189
|
+
"description": "Show detailed information",
|
|
190
|
+
"default": False,
|
|
191
|
+
},
|
|
192
|
+
"detect_secrets": {
|
|
193
|
+
"type": "boolean",
|
|
194
|
+
"description": "Run sensitive content detection",
|
|
195
|
+
"default": False,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
),
|
|
200
|
+
Tool(
|
|
201
|
+
name="aline_hide",
|
|
202
|
+
description=(
|
|
203
|
+
"Hide (redact) specific unpushed commits by rewriting git history. "
|
|
204
|
+
"This redacts commit messages and session content. "
|
|
205
|
+
"WARNING: This rewrites git history. Use with caution."
|
|
206
|
+
),
|
|
207
|
+
inputSchema={
|
|
208
|
+
"type": "object",
|
|
209
|
+
"properties": {
|
|
210
|
+
"indices": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"description": (
|
|
213
|
+
"Commit indices to hide (e.g., '1', '1,3,5', '2-4', or '--all'). "
|
|
214
|
+
"Use aline_review first to see the commit indices."
|
|
215
|
+
),
|
|
216
|
+
},
|
|
217
|
+
"repo_path": {
|
|
218
|
+
"type": "string",
|
|
219
|
+
"description": "Path to repository root (default: current directory)",
|
|
220
|
+
},
|
|
221
|
+
"force": {
|
|
222
|
+
"type": "boolean",
|
|
223
|
+
"description": "Skip confirmation prompt",
|
|
224
|
+
"default": False,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
"required": ["indices"],
|
|
228
|
+
},
|
|
229
|
+
),
|
|
173
230
|
]
|
|
174
231
|
|
|
175
232
|
|
|
@@ -189,6 +246,10 @@ async def call_tool(name: str, arguments: Any) -> list[TextContent]:
|
|
|
189
246
|
result = await handle_get_latest_session(arguments)
|
|
190
247
|
elif name == "aline_version":
|
|
191
248
|
result = await handle_version(arguments)
|
|
249
|
+
elif name == "aline_review":
|
|
250
|
+
result = await handle_review(arguments)
|
|
251
|
+
elif name == "aline_hide":
|
|
252
|
+
result = await handle_hide(arguments)
|
|
192
253
|
else:
|
|
193
254
|
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
194
255
|
|
|
@@ -395,6 +456,78 @@ async def handle_version(args: dict) -> list[TextContent]:
|
|
|
395
456
|
)]
|
|
396
457
|
|
|
397
458
|
|
|
459
|
+
async def handle_review(args: dict) -> list[TextContent]:
|
|
460
|
+
"""Handle aline_review tool."""
|
|
461
|
+
from .commands.review import review_command
|
|
462
|
+
from io import StringIO
|
|
463
|
+
import sys
|
|
464
|
+
|
|
465
|
+
repo_path = args.get("repo_path")
|
|
466
|
+
verbose = args.get("verbose", False)
|
|
467
|
+
detect_secrets = args.get("detect_secrets", False)
|
|
468
|
+
|
|
469
|
+
# Convert repo_path to Path if provided
|
|
470
|
+
repo_root = Path(repo_path) if repo_path else None
|
|
471
|
+
|
|
472
|
+
# Capture stdout
|
|
473
|
+
old_stdout = sys.stdout
|
|
474
|
+
sys.stdout = captured_output = StringIO()
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
# Call the review command
|
|
478
|
+
exit_code = review_command(
|
|
479
|
+
repo_root=repo_root,
|
|
480
|
+
verbose=verbose,
|
|
481
|
+
detect_secrets=detect_secrets
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Get the output
|
|
485
|
+
output = captured_output.getvalue()
|
|
486
|
+
|
|
487
|
+
if exit_code == 0:
|
|
488
|
+
return [TextContent(type="text", text=output or "No unpushed commits found.")]
|
|
489
|
+
else:
|
|
490
|
+
return [TextContent(type="text", text=f"Error: Review failed\n{output}")]
|
|
491
|
+
finally:
|
|
492
|
+
sys.stdout = old_stdout
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def handle_hide(args: dict) -> list[TextContent]:
|
|
496
|
+
"""Handle aline_hide tool."""
|
|
497
|
+
from .commands.hide import hide_command
|
|
498
|
+
from io import StringIO
|
|
499
|
+
import sys
|
|
500
|
+
|
|
501
|
+
indices = args["indices"]
|
|
502
|
+
repo_path = args.get("repo_path")
|
|
503
|
+
force = args.get("force", False)
|
|
504
|
+
|
|
505
|
+
# Convert repo_path to Path if provided
|
|
506
|
+
repo_root = Path(repo_path) if repo_path else None
|
|
507
|
+
|
|
508
|
+
# Capture stdout
|
|
509
|
+
old_stdout = sys.stdout
|
|
510
|
+
sys.stdout = captured_output = StringIO()
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
# Call the hide command
|
|
514
|
+
exit_code = hide_command(
|
|
515
|
+
indices=indices,
|
|
516
|
+
repo_root=repo_root,
|
|
517
|
+
force=force
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Get the output
|
|
521
|
+
output = captured_output.getvalue()
|
|
522
|
+
|
|
523
|
+
if exit_code == 0:
|
|
524
|
+
return [TextContent(type="text", text=output or "Hide operation completed.")]
|
|
525
|
+
else:
|
|
526
|
+
return [TextContent(type="text", text=f"Error: Hide failed\n{output}")]
|
|
527
|
+
finally:
|
|
528
|
+
sys.stdout = old_stdout
|
|
529
|
+
|
|
530
|
+
|
|
398
531
|
def _server_log(msg: str):
|
|
399
532
|
"""Log MCP server messages to both stderr and file."""
|
|
400
533
|
from datetime import datetime
|