Skip to content

Commit 25170b0

Browse files
finxoclaude
andauthored
feat: Add headless CLI mode for AI code review and comment workflows (#193)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 4718621 commit 25170b0

File tree

65 files changed

+5279
-1222
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+5279
-1222
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 11 deletions
This file was deleted.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ venv.bak/
134134
# IDE settings
135135
.idea/
136136
.vscode/
137+
.codex
138+
.titan/worktrees/
137139
*.swp
138140
*.swo
139141

plugins/titan-plugin-git/tests/operations/test_diff_operations.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from titan_plugin_git.operations.diff_operations import (
88
parse_diff_stat_output,
9+
expand_rename_path,
910
get_max_filename_length,
1011
colorize_diff_stats,
1112
colorize_diff_summary,
@@ -185,3 +186,32 @@ def test_format_real_git_output(self):
185186
assert "diff.py" in files[0]
186187
assert "commit.py" in files[1]
187188
assert "77 insertions" in summary[0]
189+
190+
191+
class TestExpandRenamePath:
192+
"""Tests for expand_rename_path function."""
193+
194+
def test_no_rename_unchanged(self):
195+
assert expand_rename_path("titan_cli/core/models.py") == "titan_cli/core/models.py"
196+
197+
def test_rename_with_prefix(self):
198+
result = expand_rename_path("titan_cli/core/{models.py => models/__init__.py}")
199+
assert result == "titan_cli/core/models/__init__.py"
200+
201+
def test_rename_at_root(self):
202+
result = expand_rename_path("{old_file.py => new_file.py}")
203+
assert result == "new_file.py"
204+
205+
def test_rename_with_suffix(self):
206+
result = expand_rename_path("{old_dir => new_dir}/file.py")
207+
assert result == "new_dir/file.py"
208+
209+
def test_rename_with_prefix_and_suffix(self):
210+
result = expand_rename_path("pkg/{old => new}/module.py")
211+
assert result == "pkg/new/module.py"
212+
213+
def test_simple_filename_unchanged(self):
214+
assert expand_rename_path("file.py") == "file.py"
215+
216+
def test_path_without_rename_unchanged(self):
217+
assert expand_rename_path("a/b/c/file.py") == "a/b/c/file.py"

plugins/titan-plugin-git/titan_plugin_git/clients/git_client.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,20 @@ def get_file_diff(self, file_path: str) -> ClientResult[str]:
309309
"""Get diff for a specific file."""
310310
return self.diff_service.get_file_diff(file_path)
311311

312-
def get_branch_diff(self, base_branch: str, head_branch: str) -> ClientResult[str]:
313-
"""Get diff between two branches."""
314-
return self.diff_service.get_branch_diff(base_branch, head_branch)
312+
def get_branch_diff(self, base_branch: str, head_branch: str, context_lines: int = 3, use_remote: bool = False) -> ClientResult[str]:
313+
"""
314+
Get diff between two branches.
315+
316+
Args:
317+
base_branch: Base branch name
318+
head_branch: Head branch name
319+
context_lines: Number of context lines (default: 3)
320+
use_remote: If True, both branches are treated as remote refs (default: False)
321+
322+
Returns:
323+
ClientResult[str] with diff output
324+
"""
325+
return self.diff_service.get_branch_diff(base_branch, head_branch, context_lines, use_remote)
315326

316327
def get_diff_stat(self, base_ref: str, head_ref: str = "HEAD") -> ClientResult[str]:
317328
"""Get diff stat summary."""

plugins/titan-plugin-git/titan_plugin_git/clients/services/commit_service.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Business logic for Git commit operations.
66
Uses network layer to execute commands, parses to network models, maps to view models.
77
"""
8+
import os
89
from typing import List, Optional, Sequence
910

1011
from titan_cli.core.result import ClientResult, ClientSuccess, ClientError
@@ -86,7 +87,33 @@ def commit_files(
8687
ClientResult[str] with commit hash
8788
"""
8889
try:
89-
self.git.run_command(["git", "add", "--"] + list(files))
90+
# Separate existing files from deleted ones
91+
# Only include files that exist in the filesystem OR are tracked in git
92+
existing_files = []
93+
deleted_files = []
94+
95+
for f in files:
96+
if os.path.exists(f):
97+
# File exists in filesystem
98+
existing_files.append(f)
99+
else:
100+
# File doesn't exist - check if it's tracked in git
101+
# If it's tracked, we can use git rm; if not, skip it
102+
try:
103+
self.git.run_command(["git", "ls-files", "--error-unmatch", f])
104+
# File is tracked, mark for deletion
105+
deleted_files.append(f)
106+
except GitCommandError:
107+
# File not tracked in git, skip it
108+
pass
109+
110+
# Stage existing files with git add
111+
if existing_files:
112+
self.git.run_command(["git", "add", "--"] + existing_files)
113+
114+
# Stage deleted files with git rm
115+
if deleted_files:
116+
self.git.run_command(["git", "rm", "--"] + deleted_files)
90117

91118
args = ["git", "commit", "-m", message]
92119
if no_verify:

plugins/titan-plugin-git/titan_plugin_git/clients/services/diff_service.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,20 +141,34 @@ def get_file_diff(self, file_path: str) -> ClientResult[str]:
141141
return ClientError(error_message=str(e), error_code="DIFF_ERROR")
142142

143143
@log_client_operation()
144-
def get_branch_diff(self, base_branch: str, head_branch: str) -> ClientResult[str]:
144+
def get_branch_diff(self, base_branch: str, head_branch: str, context_lines: int = 3, use_remote: bool = False) -> ClientResult[str]:
145145
"""
146146
Get diff between two branches.
147147
148148
Args:
149149
base_branch: Base branch name
150150
head_branch: Head branch name
151+
context_lines: Number of unchanged context lines around each change (default: 3).
152+
When called from code review with context_lines=20, provides extended context
153+
for AI analysis. The higher value gives better code understanding for review
154+
quality, while still keeping token usage reasonable compared to reading entire files.
155+
use_remote: If True, both branches are prefixed with the configured default_remote.
156+
Used for PR reviews where branches are remote refs only (not checked out locally).
151157
152158
Returns:
153159
ClientResult[str] with diff output
154160
"""
155161
try:
162+
# Build branch references using configured default_remote if use_remote=True
163+
if use_remote:
164+
base_ref = f"{self.default_remote}/{base_branch}"
165+
head_ref = f"{self.default_remote}/{head_branch}"
166+
else:
167+
base_ref = f"{self.default_remote}/{base_branch}"
168+
head_ref = head_branch
169+
156170
diff = self.git.run_command(
157-
["git", "diff", f"{self.default_remote}/{base_branch}...{head_branch}"],
171+
["git", "diff", f"-U{context_lines}", f"{base_ref}...{head_ref}"],
158172
check=False
159173
)
160174
return ClientSuccess(

plugins/titan-plugin-git/titan_plugin_git/operations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from .diff_operations import (
2222
parse_diff_stat_output,
23+
expand_rename_path,
2324
get_max_filename_length,
2425
colorize_diff_stats,
2526
colorize_diff_summary,
@@ -42,6 +43,7 @@
4243

4344
# Diff operations
4445
"parse_diff_stat_output",
46+
"expand_rename_path",
4547
"get_max_filename_length",
4648
"colorize_diff_stats",
4749
"colorize_diff_summary",

plugins/titan-plugin-git/titan_plugin_git/operations/diff_operations.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,36 @@
55
These functions can be used by any step and are easily testable.
66
"""
77

8+
import re
89
from typing import List, Tuple
910

11+
# Matches git rename notation: prefix{old => new}suffix
12+
# Examples:
13+
# titan_cli/core/{models.py => models/__init__.py} → titan_cli/core/models/__init__.py
14+
# {old_dir => new_dir}/file.py → new_dir/file.py
15+
_RENAME_PATTERN = re.compile(r'^(.*?)\{[^}]*\s*=>\s*([^}]*)\}(.*)$')
16+
17+
18+
def expand_rename_path(path: str) -> str:
19+
"""
20+
Expand a git rename notation path to the actual new file path.
21+
22+
Git diff --stat uses {old => new} notation for renames.
23+
This function converts it to a real file path usable by git add.
24+
25+
Args:
26+
path: A file path, possibly containing git rename notation.
27+
28+
Returns:
29+
Expanded path with the rename resolved to the new name.
30+
If no rename notation is present, returns the path unchanged.
31+
"""
32+
match = _RENAME_PATTERN.match(path)
33+
if not match:
34+
return path
35+
prefix, new_part, suffix = match.groups()
36+
return prefix + new_part.strip() + suffix
37+
1038

1139
def parse_diff_stat_output(stat_output: str) -> Tuple[List[Tuple[str, str]], List[str]]:
1240
"""
@@ -155,6 +183,7 @@ def format_diff_stat_display(stat_output: str) -> Tuple[List[str], List[str]]:
155183

156184
__all__ = [
157185
"parse_diff_stat_output",
186+
"expand_rename_path",
158187
"get_max_filename_length",
159188
"colorize_diff_stats",
160189
"colorize_diff_summary",

plugins/titan-plugin-git/titan_plugin_git/steps/diff_summary_step.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from titan_cli.core.result import ClientSuccess, ClientError
44
from titan_cli.ui.tui.widgets import SelectionOption
55
from titan_plugin_git.messages import msg
6-
from ..operations import parse_diff_stat_output, colorize_diff_stats, colorize_diff_summary, format_diff_stat_display
6+
from ..operations import parse_diff_stat_output, expand_rename_path, colorize_diff_stats, colorize_diff_summary, format_diff_stat_display
77

88

99
def show_uncommitted_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
@@ -42,7 +42,7 @@ def show_uncommitted_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
4242
max_len = max(len(filename) for filename, _ in file_lines)
4343
options = [
4444
SelectionOption(
45-
value=filename,
45+
value=expand_rename_path(filename),
4646
label=f"{filename.ljust(max_len)} |{colorize_diff_stats(stats)}",
4747
selected=True,
4848
)

0 commit comments

Comments
 (0)