Skip to content
Open
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
108 changes: 107 additions & 1 deletion src/backend/api/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import git
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

from core.branchs import Branches, get_branches
from core.diff_to_tree import ProjectTreeNode, diff_to_tree
from core.diff_utils import get_blob_content
from core.language_config import LANGUAGE_CONFIG
from core.diff_parser import parse_code_structure


class BranchRequest(BaseModel):
Expand All @@ -17,6 +21,18 @@ class DiffTreeRequest(BaseModel):
tree_mode: str = Field(default="flat")


class ConflictResolverResponse(BaseModel):
dest_code: str
source_code: str


class ConflictResolverRequest(BaseModel):
repo_path: str
id: str
base_branch: str
compare_branch: str


app = FastAPI(title="Backend API")
app.add_middleware(
CORSMiddleware,
Expand Down Expand Up @@ -50,10 +66,100 @@ async def diff_tree(payload: DiffTreeRequest) -> list[ProjectTreeNode]:

try:
tree = diff_to_tree(
payload.repo_path, payload.base_branch, payload.compare_branch, payload.tree_mode
payload.repo_path,
payload.base_branch,
payload.compare_branch,
payload.tree_mode,
)
except ValueError as exc:
# Surface a clear 400 error when the path is not a valid git repository
raise HTTPException(status_code=400, detail=str(exc)) from exc

return tree


def get_language_for_path(file_path: str) -> str:
"""Determine language from file extension."""
for lang, cfg in LANGUAGE_CONFIG.items():
for ext in cfg["extensions"]:
if file_path.endswith(ext):
return lang
return ""


def extract_def_code(
content: str, def_name: str, language: str
) -> str:
"""Extract source code for a specific definition."""
if not content:
return ""

struct = parse_code_structure(content, language)
def_info = struct.get(def_name, {})
return def_info.get("source", "")


@app.post("/get-dest-compare-code")
async def get_dest_compare_code(
payload: ConflictResolverRequest,
) -> ConflictResolverResponse:
"""
Return source and destination code for conflict resolution.

The id can be either:
- A file path (e.g., "src/file.py")
- A definition within a file (e.g., "src/file.py:my_function")
"""
try:
repo = git.Repo(payload.repo_path)

# Parse the id to extract file path and optional definition name
if ":" in payload.id:
file_path, def_name = payload.id.rsplit(":", 1)
else:
file_path = payload.id
def_name = None

# Get commit objects
target_commit = repo.commit(payload.base_branch)
source_commit = repo.commit(payload.compare_branch)

# Get file content from both branches
dest_content = get_blob_content(repo, target_commit, file_path)
source_content = get_blob_content(repo, source_commit, file_path)

# Determine language
language = get_language_for_path(file_path)
if not language:
raise ValueError(f"Unsupported file type: {file_path}")

# If specific definition is requested, extract it
if def_name:
dest_code = extract_def_code(dest_content, def_name, language)
source_code = extract_def_code(source_content, def_name, language)
else:
dest_code = dest_content
source_code = source_content

if not dest_code and not source_code:
raise ValueError(
f"Could not find code for {payload.id} "
f"in branches {payload.base_branch} "
f"or {payload.compare_branch}"
)

return ConflictResolverResponse(
dest_code=dest_code, source_code=source_code
)

except git.exc.InvalidGitRepositoryError as exc:
raise HTTPException(
status_code=400,
detail=f"{payload.repo_path!r} is not a valid git repository",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=500, detail=f"Error retrieving code: {str(exc)}"
) from exc
3 changes: 1 addition & 2 deletions src/backend/core/diff_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ProjectTreeNode(BaseModel):
children: List["ProjectTreeNode"] = Field(default_factory=list)
# Unified diff text (git‑style) for this node or file.
source: str = ""
has_conflict: bool = False


def make_code_position(def_info: dict) -> CodePosition:
Expand All @@ -34,5 +35,3 @@ def make_code_position(def_info: dict) -> CodePosition:
start_column=def_info.get("start_column", 0),
end_column=def_info.get("end_column", 0),
)


113 changes: 113 additions & 0 deletions src/backend/core/diff_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import difflib
from collections import defaultdict
from typing import List

import git
Expand Down Expand Up @@ -58,6 +59,72 @@ def build_file_diff_source(
return "\n".join(diff_lines)


def get_blob_content(repo: git.Repo, commit: git.Commit, path: str) -> str:
"""Safely get the content of a file from a specific commit."""
try:
return commit.tree[path].data_stream.read().decode("utf-8")
except (KeyError, AttributeError):
return ""


def analyze_semantic_conflicts(
repo: git.Repo,
path: str,
base_commit: git.Commit,
target_commit: git.Commit,
source_commit: git.Commit,
language: str,
) -> dict[str, str]:
"""
Analyzes a file for semantic conflicts at the function/class level.

Returns a dictionary mapping definition names to their status, e.g.,
{'my_func': 'conflict', 'other_func': 'modified_on_source'}.
"""
content_base = get_blob_content(repo, base_commit, path)
content_target = get_blob_content(repo, target_commit, path)
content_source = get_blob_content(repo, source_commit, path)

# If the file hasn't changed on the target branch, there can be no
# conflict. All changes are from the source branch.
if content_base == content_target:
return defaultdict(lambda: "modified_on_source")

struct_base = parse_code_structure(content_base, language)
struct_target = parse_code_structure(content_target, language)
struct_source = parse_code_structure(content_source, language)

all_keys = (
set(struct_base.keys())
| set(struct_target.keys())
| set(struct_source.keys())
)
conflict_map = {}

for name in all_keys:
src_base = struct_base.get(name, {}).get("source")
src_target = struct_target.get(name, {}).get("source")
src_source = struct_source.get(name, {}).get("source")

changed_on_target = src_base != src_target
changed_on_source = src_base != src_source

if changed_on_target and changed_on_source:
# The definition was changed on BOTH branches.
# If the final source is different, it's a conflict.
if src_target != src_source:
conflict_map[name] = "conflict"
else:
# Convergent change: both branches made the same change.
conflict_map[name] = "modified_on_both"
elif changed_on_target:
conflict_map[name] = "modified_on_target"
elif changed_on_source:
conflict_map[name] = "modified_on_source"

return conflict_map


def build_project_tree_from_branch_diff(
repo: git.Repo, base_branch: str, compare_branch: str, tree_mode: str
) -> list[ProjectTreeNode]:
Expand Down Expand Up @@ -89,6 +156,10 @@ def build_project_tree_from_branch_diff(
if not diff_index:
return []

# Get commits for semantic conflict analysis
target_commit = repo.commit(base_branch)
source_commit = repo.commit(compare_branch)

# First collect a flat list of file‑level nodes; we'll wrap these in a
# folder hierarchy once we've processed the whole diff.
file_nodes: List[ProjectTreeNode] = []
Expand Down Expand Up @@ -132,6 +203,11 @@ def build_project_tree_from_branch_diff(
content_compare,
)

# Analyze semantic conflicts at the definition level for this file
def_conflict_map = analyze_semantic_conflicts(
repo, path, base_for_diff, target_commit, source_commit, language
)

struct_base = parse_code_structure(content_base, language)
struct_compare = parse_code_structure(content_compare, language)

Expand Down Expand Up @@ -178,6 +254,8 @@ def build_project_tree_from_branch_diff(
file_node = ProjectTreeNode(
id=path,
label=path,
# Files don't have conflicts; only definitions do
has_conflict=False,
kind="file",
status=file_status,
code_position=CodePosition(
Expand Down Expand Up @@ -205,11 +283,16 @@ def build_project_tree_from_branch_diff(
"",
info.get("source", ""),
)
# Check if this definition has a conflict
conflict_status = def_conflict_map.get(name)
has_def_conflict = conflict_status == "conflict"

def_nodes[name] = ProjectTreeNode(
id=f"{path}:{name}",
label=name.split(".")[-1],
kind=def_type,
status="added",
has_conflict=has_def_conflict,
code_position=make_code_position(info),
path=path,
source=diff_source,
Expand All @@ -225,12 +308,17 @@ def build_project_tree_from_branch_diff(
info.get("source", ""),
"",
)
# Check if this definition has a conflict
conflict_status = def_conflict_map.get(name)
has_def_conflict = conflict_status == "conflict"

def_nodes[name] = ProjectTreeNode(
id=f"{path}:{name}",
label=name.split(".")[-1],
kind=def_type,
status="removed",
code_position=make_code_position(info),
has_conflict=has_def_conflict,
path=path,
source=diff_source,
)
Expand All @@ -250,6 +338,11 @@ def build_project_tree_from_branch_diff(
)
# Use the "new" position where possible
position_source = compare_info or base_info

# Check if this definition has a conflict
conflict_status = def_conflict_map.get(name)
has_def_conflict = conflict_status == "conflict"

def_nodes[name] = ProjectTreeNode(
id=f"{path}:{name}",
label=name.split(".")[-1],
Expand All @@ -258,6 +351,7 @@ def build_project_tree_from_branch_diff(
code_position=make_code_position(position_source),
path=path,
source=diff_source,
has_conflict=has_def_conflict,
)

# Attach nodes to the correct parents based on qualified name.
Expand All @@ -269,7 +363,26 @@ def build_project_tree_from_branch_diff(
else:
children.append(node)

# Propagate conflicts from children to parents:
# If any child has a conflict, mark the parent as conflicted too.
def propagate_conflicts(nodes: List[ProjectTreeNode]) -> None:
"""Propagate conflict status from children to parents."""
for node in nodes:
if node.children:
propagate_conflicts(node.children)
# If any child has a conflict, mark parent as conflicted
if any(
child.has_conflict for child in node.children
):
node.has_conflict = True

propagate_conflicts(children)

file_node.children = children
# File also inherits conflicts from its definitions
if any(child.has_conflict for child in children):
file_node.has_conflict = True

file_nodes.append(file_node)

if tree_mode == "flat":
Expand Down
Binary file modified src/frontend/.yarn/install-state.gz
Binary file not shown.
1 change: 1 addition & 0 deletions src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.553.0",
"monaco-editor": "^0.54.0",
"react": "^19.2.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.2.0",
Expand Down
28 changes: 24 additions & 4 deletions src/frontend/src/features/DiffPage/ProjectTree/ProjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import ProjectTreeItem from "./ProjectTreeItem";
import type { ProjectTreeNode } from "./types";
import { Toggle } from "@/components/ui/toggle";
import { FolderTree } from "lucide-react";
import { FolderTree, AlertTriangle } from "lucide-react";
import { Input } from "@/components/ui/input";
import ProjectTreeFilterPopover from "./ProjectTreeFilterPopover";
import { filter as rFilter } from "remeda";
Expand Down Expand Up @@ -256,6 +256,18 @@ const ProjectTree: FC<ProjectTreeProps> = ({
return walk(filteredData);
}, [filteredData]);

const conflictCount = useMemo(() => {
const walk = (items: ProjectTreeNode[]): number =>
items.reduce(
(acc, item) =>
acc +
(item.has_conflict ? 1 : 0) +
(item.children ? walk(item.children) : 0),
0
);
return walk(treeData);
}, [treeData]);

return (
<div
className={cn(
Expand All @@ -267,9 +279,17 @@ const ProjectTree: FC<ProjectTreeProps> = ({
<p className="text-xs font-medium text-muted-foreground">
Function/Class changes
</p>
<span className="text-[10px] text-muted-foreground">
{isLoading ? "…" : flatCount}
</span>
<div className="flex items-center gap-2">
{conflictCount > 0 && (
<span className="inline-flex items-center rounded-full bg-red-500/10 px-1.5 py-0.5 text-[9px] font-medium text-red-600">
<AlertTriangle className="mr-1 h-3 w-3" />
{conflictCount}
</span>
)}
<span className="text-[10px] text-muted-foreground">
{isLoading ? "…" : flatCount}
</span>
</div>
</div>

<div className="border-b px-3 py-2 flex flex-row gap-1 items-center w-full">
Expand Down
Loading