Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 183 additions & 1 deletion codeframe/core/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"""

import logging
from dataclasses import dataclass
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -55,6 +56,27 @@ class CommitResult:
files_changed: int


@dataclass
class FileChange:
"""Per-file change statistics from a diff."""

path: str
change_type: str # "modified", "added", "deleted", "renamed"
insertions: int = 0
deletions: int = 0


@dataclass
class DiffStats:
"""Parsed diff statistics."""

diff: str
files_changed: int
insertions: int
deletions: int
changed_files: list[FileChange] = field(default_factory=list)


# ============================================================================
# Git Operations
# ============================================================================
Expand Down Expand Up @@ -293,3 +315,163 @@ def is_clean(workspace: Workspace) -> bool:
"""
repo = _get_repo(workspace)
return not repo.is_dirty(untracked_files=True)


def get_diff_stats(workspace: Workspace, staged: bool = False) -> DiffStats:
"""Get diff with parsed statistics.

Args:
workspace: Target workspace
staged: If True, show staged changes; if False, show unstaged

Returns:
DiffStats with parsed per-file statistics
"""
repo = _get_repo(workspace)
diff_text = get_diff(workspace, staged=staged)

if not diff_text.strip():
return DiffStats(diff=diff_text, files_changed=0, insertions=0, deletions=0)

# Use git diff --stat for accurate statistics
try:
if staged:
stat_output = repo.git.diff("--cached", "--numstat") if repo.head.is_valid() else ""
else:
stat_output = repo.git.diff("--numstat")
except git.GitCommandError:
stat_output = ""

changed_files: list[FileChange] = []
total_insertions = 0
total_deletions = 0

for line in stat_output.strip().split("\n"):
if not line.strip():
continue
parts = line.split("\t")
if len(parts) >= 3:
ins_str, del_str, file_path = parts[0], parts[1], parts[2]
ins = int(ins_str) if ins_str != "-" else 0
dels = int(del_str) if del_str != "-" else 0
total_insertions += ins
total_deletions += dels

# Extract per-file section from diff for accurate change type detection
file_section_match = re.search(
rf"diff --git a/.*? b/{re.escape(file_path)}\n(.*?)(?=diff --git|\Z)",
diff_text,
re.DOTALL,
)
file_section = file_section_match.group(0) if file_section_match else ""

change_type = "modified"
if "new file mode" in file_section:
change_type = "added"
elif "deleted file mode" in file_section:
change_type = "deleted"
elif "rename from" in file_section:
change_type = "renamed"

changed_files.append(FileChange(
path=file_path,
change_type=change_type,
insertions=ins,
deletions=dels,
))

return DiffStats(
diff=diff_text,
files_changed=len(changed_files),
insertions=total_insertions,
deletions=total_deletions,
changed_files=changed_files,
)


def get_patch(workspace: Workspace, staged: bool = False) -> str:
"""Get patch-formatted diff for export.

Args:
workspace: Target workspace
staged: If True, show staged changes; if False, show unstaged

Returns:
Patch content as string (with full headers for git apply)
"""
repo = _get_repo(workspace)

try:
if staged:
if repo.head.is_valid():
return repo.git.diff("--cached", "--patch", "--full-index")
return ""
else:
return repo.git.diff("--patch", "--full-index")
except git.GitCommandError as e:
logger.warning(f"Failed to get patch: {e}")
return ""


def generate_commit_message(workspace: Workspace, staged: bool = False) -> str:
"""Generate a commit message from the current diff.

Uses heuristic analysis of changed files and diff content to suggest
a conventional commit message. Does not require LLM.

Args:
workspace: Target workspace
staged: If True, analyze staged changes; if False, unstaged

Returns:
Suggested commit message string
"""
stats = get_diff_stats(workspace, staged=staged)

if not stats.changed_files:
return ""

files = stats.changed_files
file_count = len(files)

# Determine primary action from change types
added = [f for f in files if f.change_type == "added"]
deleted = [f for f in files if f.change_type == "deleted"]
modified = [f for f in files if f.change_type == "modified"]

# Pick prefix based on dominant change type
if len(added) > len(modified) and len(added) > len(deleted):
prefix = "feat"
action = "add"
elif len(deleted) > len(modified):
prefix = "refactor"
action = "remove"
else:
prefix = "feat"
action = "update"

# Detect common patterns
test_files = [f for f in files if "test" in f.path.lower()]
if test_files and len(test_files) == file_count:
prefix = "test"
action = "add" if added else "update"

config_files = [f for f in files if f.path.endswith((".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"))]
if config_files and len(config_files) == file_count:
prefix = "chore"
action = "update"

# Build description
if file_count == 1:
file_path = files[0].path
name = Path(file_path).stem
description = f"{action} {name}"
else:
# Find common directory
dirs = set(str(Path(f.path).parent) for f in files)
if len(dirs) == 1 and list(dirs)[0] != ".":
description = f"{action} {list(dirs)[0]} ({file_count} files)"
else:
description = f"{action} {file_count} files"

return f"{prefix}: {description}"
155 changes: 154 additions & 1 deletion codeframe/ui/routers/review_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from codeframe.core.workspace import Workspace
from codeframe.lib.rate_limiter import rate_limit_standard
from codeframe.core import review
from codeframe.core import review, git
from codeframe.ui.dependencies import get_v2_workspace

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -71,6 +71,38 @@ class ReviewTaskRequest(BaseModel):
files_modified: list[str] = Field(..., min_length=1, description="Modified files to review")


class FileChangeResponse(BaseModel):
"""Per-file change statistics."""

path: str
change_type: str
insertions: int
deletions: int


class DiffStatsResponse(BaseModel):
"""Response model for diff with statistics."""

diff: str
files_changed: int
insertions: int
deletions: int
changed_files: list[FileChangeResponse]


class PatchResponse(BaseModel):
"""Response model for patch export."""

patch: str
filename: str


class CommitMessageResponse(BaseModel):
"""Response model for generated commit message."""

message: str


# ============================================================================
# Review Endpoints
# ============================================================================
Expand Down Expand Up @@ -205,3 +237,124 @@ async def review_files_summary(
except Exception as e:
logger.error(f"Failed to get review summary: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))


# ============================================================================
# Diff & Patch Endpoints (for Review & Commit View)
# ============================================================================


@router.get("/diff", response_model=DiffStatsResponse)
@rate_limit_standard()
async def get_review_diff(
request: Request,
staged: bool = False,
workspace: Workspace = Depends(get_v2_workspace),
) -> DiffStatsResponse:
"""Get unified diff with parsed statistics.

Returns the raw diff plus per-file change counts for display
in the Review & Commit View.

Args:
request: HTTP request for rate limiting
staged: If True, show staged changes; if False, show unstaged
workspace: v2 Workspace

Returns:
DiffStatsResponse with diff text and statistics
"""
try:
stats = git.get_diff_stats(workspace, staged=staged)

return DiffStatsResponse(
diff=stats.diff,
files_changed=stats.files_changed,
insertions=stats.insertions,
deletions=stats.deletions,
changed_files=[
FileChangeResponse(
path=f.path,
change_type=f.change_type,
insertions=f.insertions,
deletions=f.deletions,
)
for f in stats.changed_files
],
)

except ValueError as e:
logger.error(f"Get review diff failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get review diff: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))


@router.get("/patch", response_model=PatchResponse)
@rate_limit_standard()
async def get_review_patch(
request: Request,
staged: bool = False,
workspace: Workspace = Depends(get_v2_workspace),
) -> PatchResponse:
"""Get patch-formatted diff for export.

Returns the diff in patch format suitable for `git apply`.

Args:
request: HTTP request for rate limiting
staged: If True, show staged changes; if False, show unstaged
workspace: v2 Workspace

Returns:
PatchResponse with patch content and suggested filename
"""
try:
patch_content = git.get_patch(workspace, staged=staged)
branch = git.get_current_branch(workspace)
filename = f"{branch.replace('/', '-')}.patch"

return PatchResponse(
patch=patch_content,
filename=filename,
)

except ValueError as e:
logger.error(f"Get patch failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get patch: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))


@router.post("/commit-message", response_model=CommitMessageResponse)
@rate_limit_standard()
async def generate_commit_message(
request: Request,
staged: bool = False,
workspace: Workspace = Depends(get_v2_workspace),
) -> CommitMessageResponse:
"""Generate a commit message from the current diff.

Analyzes changed files to suggest a conventional commit message.

Args:
request: HTTP request for rate limiting
staged: If True, analyze staged changes; if False, unstaged
workspace: v2 Workspace

Returns:
CommitMessageResponse with suggested message
"""
try:
message = git.generate_commit_message(workspace, staged=staged)

return CommitMessageResponse(message=message)

except ValueError as e:
logger.error(f"Generate commit message failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to generate commit message: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
Loading
Loading