     1→"""
     2→Git Router
     3→==========
     4→
     5→REST and WebSocket endpoints for git operations and status.
     6→"""
     7→
     8→import asyncio
     9→import json
    10→import logging
    11→import re
    12→from pathlib import Path
    13→from typing import Optional
    14→
    15→from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
    16→from pydantic import BaseModel
    17→
    18→from ..services.git_operations import GitOperations, GitStatus, GitResult
    19→
    20→logger = logging.getLogger(__name__)
    21→
    22→router = APIRouter(prefix="/api/git", tags=["git"])
    23→
    24→
    25→def _get_project_path(project_name: str) -> Path:
    26→    """Get project path from registry."""
    27→    import sys
    28→    root = Path(__file__).parent.parent.parent
    29→    if str(root) not in sys.path:
    30→        sys.path.insert(0, str(root))
    31→
    32→    from registry import get_project_path
    33→    return get_project_path(project_name)
    34→
    35→
    36→def validate_project_name(name: str) -> bool:
    37→    """Validate project name to prevent path traversal."""
    38→    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))
    39→
    40→
    41→# ============================================================================
    42→# Response Models
    43→# ============================================================================
    44→
    45→class GitStatusResponse(BaseModel):
    46→    """Git status response."""
    47→    branch: str
    48→    ahead: int
    49→    behind: int
    50→    staged: list[str]
    51→    unstaged: list[str]
    52→    untracked: list[str]
    53→    has_conflicts: bool
    54→    is_clean: bool
    55→    remote: Optional[str]
    56→    last_commit_hash: Optional[str]
    57→    last_commit_message: Optional[str]
    58→    last_commit_time: Optional[str]
    59→
    60→
    61→class GitCommitResponse(BaseModel):
    62→    """Git commit info."""
    63→    hash: str
    64→    short_hash: str
    65→    message: str
    66→    author: str
    67→    date: str
    68→
    69→
    70→class GitResultResponse(BaseModel):
    71→    """Generic git operation result."""
    72→    success: bool
    73→    output: str
    74→    error: Optional[str]
    75→    commit_hash: Optional[str]
    76→
    77→
    78→class CommitRequest(BaseModel):
    79→    """Request to create a commit."""
    80→    message: str
    81→    files: Optional[list[str]] = None
    82→
    83→
    84→class PushRequest(BaseModel):
    85→    """Request to push commits."""
    86→    remote: str = "origin"
    87→    branch: Optional[str] = None
    88→    force: bool = False
    89→    set_upstream: bool = False
    90→
    91→
    92→class PullRequest(BaseModel):
    93→    """Request to pull changes."""
    94→    remote: str = "origin"
    95→    branch: Optional[str] = None
    96→    rebase: bool = False
    97→
    98→
    99→class CheckoutRequest(BaseModel):
   100→    """Request to checkout a branch."""
   101→    branch: str
   102→    create: bool = False
   103→
   104→
   105→# ============================================================================
   106→# REST Endpoints
   107→# ============================================================================
   108→
   109→@router.get("/projects/{project_name}/status", response_model=GitStatusResponse)
   110→async def get_status(project_name: str):
   111→    """Get git status for a project."""
   112→    if not validate_project_name(project_name):
   113→        raise HTTPException(status_code=400, detail="Invalid project name")
   114→
   115→    project_dir = _get_project_path(project_name)
   116→    if not project_dir:
   117→        raise HTTPException(status_code=404, detail="Project not found")
   118→
   119→    git = GitOperations(project_dir)
   120→
   121→    if not await git.is_repo():
   122→        raise HTTPException(status_code=400, detail="Not a git repository")
   123→
   124→    status = await git.status()
   125→
   126→    return GitStatusResponse(
   127→        branch=status.branch,
   128→        ahead=status.ahead,
   129→        behind=status.behind,
   130→        staged=status.staged,
   131→        unstaged=status.unstaged,
   132→        untracked=status.untracked,
   133→        has_conflicts=status.has_conflicts,
   134→        is_clean=status.is_clean,
   135→        remote=status.remote,
   136→        last_commit_hash=status.last_commit_hash,
   137→        last_commit_message=status.last_commit_message,
   138→        last_commit_time=status.last_commit_time,
   139→    )
   140→
   141→
   142→@router.get("/projects/{project_name}/log", response_model=list[GitCommitResponse])
   143→async def get_log(
   144→    project_name: str,
   145→    count: int = 10,
   146→    file: Optional[str] = None,
   147→):
   148→    """Get commit history for a project."""
   149→    if not validate_project_name(project_name):
   150→        raise HTTPException(status_code=400, detail="Invalid project name")
   151→
   152→    project_dir = _get_project_path(project_name)
   153→    if not project_dir:
   154→        raise HTTPException(status_code=404, detail="Project not found")
   155→
   156→    git = GitOperations(project_dir)
   157→
   158→    if not await git.is_repo():
   159→        raise HTTPException(status_code=400, detail="Not a git repository")
   160→
   161→    commits = await git.log(count=min(count, 100), file=file)
   162→
   163→    return [
   164→        GitCommitResponse(
   165→            hash=c.hash,
   166→            short_hash=c.short_hash,
   167→            message=c.message,
   168→            author=c.author,
   169→            date=c.date,
   170→        )
   171→        for c in commits
   172→    ]
   173→
   174→
   175→@router.get("/projects/{project_name}/diff")
   176→async def get_diff(
   177→    project_name: str,
   178→    staged: bool = False,
   179→    file: Optional[str] = None,
   180→):
   181→    """Get diff of changes."""
   182→    if not validate_project_name(project_name):
   183→        raise HTTPException(status_code=400, detail="Invalid project name")
   184→
   185→    project_dir = _get_project_path(project_name)
   186→    if not project_dir:
   187→        raise HTTPException(status_code=404, detail="Project not found")
   188→
   189→    git = GitOperations(project_dir)
   190→
   191→    if not await git.is_repo():
   192→        raise HTTPException(status_code=400, detail="Not a git repository")
   193→
   194→    diff = await git.diff(staged=staged, file=file)
   195→
   196→    return {"diff": diff}
   197→
   198→
   199→@router.post("/projects/{project_name}/add", response_model=GitResultResponse)
   200→async def stage_files(project_name: str, files: Optional[list[str]] = None):
   201→    """Stage files for commit."""
   202→    if not validate_project_name(project_name):
   203→        raise HTTPException(status_code=400, detail="Invalid project name")
   204→
   205→    project_dir = _get_project_path(project_name)
   206→    if not project_dir:
   207→        raise HTTPException(status_code=404, detail="Project not found")
   208→
   209→    git = GitOperations(project_dir)
   210→
   211→    if not await git.is_repo():
   212→        raise HTTPException(status_code=400, detail="Not a git repository")
   213→
   214→    result = await git.add(files)
   215→
   216→    return GitResultResponse(
   217→        success=result.success,
   218→        output=result.output,
   219→        error=result.error,
   220→        commit_hash=result.commit_hash,
   221→    )
   222→
   223→
   224→@router.post("/projects/{project_name}/commit", response_model=GitResultResponse)
   225→async def create_commit(project_name: str, request: CommitRequest):
   226→    """Create a commit."""
   227→    if not validate_project_name(project_name):
   228→        raise HTTPException(status_code=400, detail="Invalid project name")
   229→
   230→    project_dir = _get_project_path(project_name)
   231→    if not project_dir:
   232→        raise HTTPException(status_code=404, detail="Project not found")
   233→
   234→    git = GitOperations(project_dir)
   235→
   236→    if not await git.is_repo():
   237→        raise HTTPException(status_code=400, detail="Not a git repository")
   238→
   239→    result = await git.commit(request.message, files=request.files)
   240→
   241→    return GitResultResponse(
   242→        success=result.success,
   243→        output=result.output,
   244→        error=result.error,
   245→        commit_hash=result.commit_hash,
   246→    )
   247→
   248→
   249→@router.post("/projects/{project_name}/push", response_model=GitResultResponse)
   250→async def push_commits(project_name: str, request: Optional[PushRequest] = None):
   251→    """Push commits to remote."""
   252→    if not validate_project_name(project_name):
   253→        raise HTTPException(status_code=400, detail="Invalid project name")
   254→
   255→    project_dir = _get_project_path(project_name)
   256→    if not project_dir:
   257→        raise HTTPException(status_code=404, detail="Project not found")
   258→
   259→    git = GitOperations(project_dir)
   260→
   261→    if not await git.is_repo():
   262→        raise HTTPException(status_code=400, detail="Not a git repository")
   263→
   264→    if request is None:
   265→        request = PushRequest()
   266→
   267→    result = await git.push(
   268→        remote=request.remote,
   269→        branch=request.branch,
   270→        force=request.force,
   271→        set_upstream=request.set_upstream,
   272→    )
   273→
   274→    return GitResultResponse(
   275→        success=result.success,
   276→        output=result.output,
   277→        error=result.error,
   278→        commit_hash=result.commit_hash,
   279→    )
   280→
   281→
   282→@router.post("/projects/{project_name}/pull", response_model=GitResultResponse)
   283→async def pull_changes(project_name: str, request: Optional[PullRequest] = None):
   284→    """Pull changes from remote."""
   285→    if not validate_project_name(project_name):
   286→        raise HTTPException(status_code=400, detail="Invalid project name")
   287→
   288→    project_dir = _get_project_path(project_name)
   289→    if not project_dir:
   290→        raise HTTPException(status_code=404, detail="Project not found")
   291→
   292→    git = GitOperations(project_dir)
   293→
   294→    if not await git.is_repo():
   295→        raise HTTPException(status_code=400, detail="Not a git repository")
   296→
   297→    if request is None:
   298→        request = PullRequest()
   299→
   300→    result = await git.pull(
   301→        remote=request.remote,
   302→        branch=request.branch,
   303→        rebase=request.rebase,
   304→    )
   305→
   306→    return GitResultResponse(
   307→        success=result.success,
   308→        output=result.output,
   309→        error=result.error,
   310→        commit_hash=result.commit_hash,
   311→    )
   312→
   313→
   314→@router.post("/projects/{project_name}/fetch", response_model=GitResultResponse)
   315→async def fetch_changes(project_name: str, remote: str = "origin"):
   316→    """Fetch changes from remote without merging."""
   317→    if not validate_project_name(project_name):
   318→        raise HTTPException(status_code=400, detail="Invalid project name")
   319→
   320→    project_dir = _get_project_path(project_name)
   321→    if not project_dir:
   322→        raise HTTPException(status_code=404, detail="Project not found")
   323→
   324→    git = GitOperations(project_dir)
   325→
   326→    if not await git.is_repo():
   327→        raise HTTPException(status_code=400, detail="Not a git repository")
   328→
   329→    result = await git.fetch(remote)
   330→
   331→    return GitResultResponse(
   332→        success=result.success,
   333→        output=result.output,
   334→        error=result.error,
   335→        commit_hash=result.commit_hash,
   336→    )
   337→
   338→
   339→@router.post("/projects/{project_name}/checkout", response_model=GitResultResponse)
   340→async def checkout_branch(project_name: str, request: CheckoutRequest):
   341→    """Checkout a branch."""
   342→    if not validate_project_name(project_name):
   343→        raise HTTPException(status_code=400, detail="Invalid project name")
   344→
   345→    project_dir = _get_project_path(project_name)
   346→    if not project_dir:
   347→        raise HTTPException(status_code=404, detail="Project not found")
   348→
   349→    git = GitOperations(project_dir)
   350→
   351→    if not await git.is_repo():
   352→        raise HTTPException(status_code=400, detail="Not a git repository")
   353→
   354→    result = await git.checkout(request.branch, create=request.create)
   355→
   356→    return GitResultResponse(
   357→        success=result.success,
   358→        output=result.output,
   359→        error=result.error,
   360→        commit_hash=result.commit_hash,
   361→    )
   362→
   363→
   364→# ============================================================================
   365→# WebSocket Endpoint for Real-time Updates
   366→# ============================================================================
   367→
   368→@router.websocket("/projects/{project_name}/ws")
   369→async def git_websocket(websocket: WebSocket, project_name: str):
   370→    """
   371→    WebSocket endpoint for real-time git status updates.
   372→
   373→    Message protocol:
   374→
   375→    Client -> Server:
   376→    - {"type": "subscribe"} - Subscribe to git status updates
   377→    - {"type": "unsubscribe"} - Unsubscribe from updates
   378→    - {"type": "refresh"} - Force refresh status
   379→    - {"type": "ping"} - Keep-alive ping
   380→
   381→    Server -> Client:
   382→    - {"type": "status", "data": {...}} - Git status update
   383→    - {"type": "error", "content": "..."} - Error message
   384→    - {"type": "pong"} - Keep-alive pong
   385→    """
   386→    if not validate_project_name(project_name):
   387→        await websocket.close(code=4000, reason="Invalid project name")
   388→        return
   389→
   390→    project_dir = _get_project_path(project_name)
   391→    if not project_dir:
   392→        await websocket.close(code=4004, reason="Project not found")
   393→        return
   394→
   395→    await websocket.accept()
   396→
   397→    git = GitOperations(project_dir)
   398→    subscribed = False
   399→    poll_task: Optional[asyncio.Task] = None
   400→
   401→    async def poll_status():
   402→        """Poll git status and send updates."""
   403→        last_status = None
   404→        while True:
   405→            try:
   406→                status = await git.status()
   407→                status_dict = {
   408→                    "branch": status.branch,
   409→                    "ahead": status.ahead,
   410→                    "behind": status.behind,
   411→                    "staged": status.staged,
   412→                    "unstaged": status.unstaged,
   413→                    "untracked": status.untracked,
   414→                    "has_conflicts": status.has_conflicts,
   415→                    "is_clean": status.is_clean,
   416→                }
   417→
   418→                # Only send if changed
   419→                if status_dict != last_status:
   420→                    await websocket.send_json({
   421→                        "type": "status",
   422→                        "data": status_dict,
   423→                    })
   424→                    last_status = status_dict
   425→
   426→                await asyncio.sleep(2)  # Poll every 2 seconds
   427→
   428→            except asyncio.CancelledError:
   429→                break
   430→            except Exception as e:
   431→                logger.error(f"Error polling git status: {e}")
   432→                await asyncio.sleep(5)
   433→
   434→    try:
   435→        while True:
   436→            try:
   437→                data = await websocket.receive_text()
   438→                message = json.loads(data)
   439→                msg_type = message.get("type")
   440→
   441→                if msg_type == "ping":
   442→                    await websocket.send_json({"type": "pong"})
   443→
   444→                elif msg_type == "subscribe":
   445→                    if not subscribed:
   446→                        subscribed = True
   447→                        poll_task = asyncio.create_task(poll_status())
   448→
   449→                elif msg_type == "unsubscribe":
   450→                    if poll_task:
   451→                        poll_task.cancel()
   452→                        poll_task = None
   453→                    subscribed = False
   454→
   455→                elif msg_type == "refresh":
   456→                    status = await git.status()
   457→                    await websocket.send_json({
   458→                        "type": "status",
   459→                        "data": {
   460→                            "branch": status.branch,
   461→                            "ahead": status.ahead,
   462→                            "behind": status.behind,
   463→                            "staged": status.staged,
   464→                            "unstaged": status.unstaged,
   465→                            "untracked": status.untracked,
   466→                            "has_conflicts": status.has_conflicts,
   467→                            "is_clean": status.is_clean,
   468→                        },
   469→                    })
   470→
   471→                else:
   472→                    await websocket.send_json({
   473→                        "type": "error",
   474→                        "content": f"Unknown message type: {msg_type}"
   475→                    })
   476→
   477→            except json.JSONDecodeError:
   478→                await websocket.send_json({
   479→                    "type": "error",
   480→                    "content": "Invalid JSON message"
   481→                })
   482→
   483→    except WebSocketDisconnect:
   484→        logger.info(f"Git WebSocket disconnected for project: {project_name}")
   485→    except Exception as e:
   486→        logger.exception(f"Git WebSocket error for project {project_name}: {e}")
   487→    finally:
   488→        if poll_task:
   489→            poll_task.cancel()
   490→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Git Router
==========

REST and WebSocket endpoints for git operations and status.
"""

import asyncio
import json
import logging
import re
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel

from ..services.git_operations import GitOperations, GitStatus, GitResult

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/git", tags=["git"])

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def validate_project_name(name: str) -> bool:
    """Validate project name to prevent path traversal."""
    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))

# ============================================================================
# Response Models
# ============================================================================

class GitStatusResponse(BaseModel):
    """Git status response."""
    branch: str
    ahead: int
    behind: int
    staged: list[str]
    unstaged: list[str]
    untracked: list[str]
    has_conflicts: bool
    is_clean: bool
    remote: Optional[str]
    last_commit_hash: Optional[str]
    last_commit_message: Optional[str]
    last_commit_time: Optional[str]

class GitCommitResponse(BaseModel):
    """Git commit info."""
    hash: str
    short_hash: str
    message: str
    author: str
    date: str

class GitResultResponse(BaseModel):
    """Generic git operation result."""
    success: bool
    output: str
    error: Optional[str]
    commit_hash: Optional[str]

class CommitRequest(BaseModel):
    """Request to create a commit."""
    message: str
    files: Optional[list[str]] = None

class PushRequest(BaseModel):
    """Request to push commits."""
    remote: str = "origin"
    branch: Optional[str] = None
    force: bool = False
    set_upstream: bool = False

class PullRequest(BaseModel):
    """Request to pull changes."""
    remote: str = "origin"
    branch: Optional[str] = None
    rebase: bool = False

class CheckoutRequest(BaseModel):
    """Request to checkout a branch."""
    branch: str
    create: bool = False

# ============================================================================
# REST Endpoints
# ============================================================================

@router.get("/projects/{project_name}/status", response_model=GitStatusResponse)
async def get_status(project_name: str):
    """Get git status for a project."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    status = await git.status()

    return GitStatusResponse(
        branch=status.branch,
        ahead=status.ahead,
        behind=status.behind,
        staged=status.staged,
        unstaged=status.unstaged,
        untracked=status.untracked,
        has_conflicts=status.has_conflicts,
        is_clean=status.is_clean,
        remote=status.remote,
        last_commit_hash=status.last_commit_hash,
        last_commit_message=status.last_commit_message,
        last_commit_time=status.last_commit_time,
    )

@router.get("/projects/{project_name}/log", response_model=list[GitCommitResponse])
async def get_log(
    project_name: str,
    count: int = 10,
    file: Optional[str] = None,
):
    """Get commit history for a project."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    commits = await git.log(count=min(count, 100), file=file)

    return [
        GitCommitResponse(
            hash=c.hash,
            short_hash=c.short_hash,
            message=c.message,
            author=c.author,
            date=c.date,
        )
        for c in commits
    ]

@router.get("/projects/{project_name}/diff")
async def get_diff(
    project_name: str,
    staged: bool = False,
    file: Optional[str] = None,
):
    """Get diff of changes."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    diff = await git.diff(staged=staged, file=file)

    return {"diff": diff}

@router.post("/projects/{project_name}/add", response_model=GitResultResponse)
async def stage_files(project_name: str, files: Optional[list[str]] = None):
    """Stage files for commit."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    result = await git.add(files)

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

@router.post("/projects/{project_name}/commit", response_model=GitResultResponse)
async def create_commit(project_name: str, request: CommitRequest):
    """Create a commit."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    result = await git.commit(request.message, files=request.files)

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

@router.post("/projects/{project_name}/push", response_model=GitResultResponse)
async def push_commits(project_name: str, request: Optional[PushRequest] = None):
    """Push commits to remote."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    if request is None:
        request = PushRequest()

    result = await git.push(
        remote=request.remote,
        branch=request.branch,
        force=request.force,
        set_upstream=request.set_upstream,
    )

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

@router.post("/projects/{project_name}/pull", response_model=GitResultResponse)
async def pull_changes(project_name: str, request: Optional[PullRequest] = None):
    """Pull changes from remote."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    if request is None:
        request = PullRequest()

    result = await git.pull(
        remote=request.remote,
        branch=request.branch,
        rebase=request.rebase,
    )

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

@router.post("/projects/{project_name}/fetch", response_model=GitResultResponse)
async def fetch_changes(project_name: str, remote: str = "origin"):
    """Fetch changes from remote without merging."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    result = await git.fetch(remote)

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

@router.post("/projects/{project_name}/checkout", response_model=GitResultResponse)
async def checkout_branch(project_name: str, request: CheckoutRequest):
    """Checkout a branch."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found")

    git = GitOperations(project_dir)

    if not await git.is_repo():
        raise HTTPException(status_code=400, detail="Not a git repository")

    result = await git.checkout(request.branch, create=request.create)

    return GitResultResponse(
        success=result.success,
        output=result.output,
        error=result.error,
        commit_hash=result.commit_hash,
    )

# ============================================================================
# WebSocket Endpoint for Real-time Updates
# ============================================================================

@router.websocket("/projects/{project_name}/ws")
async def git_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for real-time git status updates.

    Message protocol:

    Client -> Server:
    - {"type": "subscribe"} - Subscribe to git status updates
    - {"type": "unsubscribe"} - Unsubscribe from updates
    - {"type": "refresh"} - Force refresh status
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "status", "data": {...}} - Git status update
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, reason="Invalid project name")
        return

    project_dir = _get_project_path(project_name)
    if not project_dir:
        await websocket.close(code=4004, reason="Project not found")
        return

    await websocket.accept()

    git = GitOperations(project_dir)
    subscribed = False
    poll_task: Optional[asyncio.Task] = None

    async def poll_status():
        """Poll git status and send updates."""
        last_status = None
        while True:
            try:
                status = await git.status()
                status_dict = {
                    "branch": status.branch,
                    "ahead": status.ahead,
                    "behind": status.behind,
                    "staged": status.staged,
                    "unstaged": status.unstaged,
                    "untracked": status.untracked,
                    "has_conflicts": status.has_conflicts,
                    "is_clean": status.is_clean,
                }

                # Only send if changed
                if status_dict != last_status:
                    await websocket.send_json({
                        "type": "status",
                        "data": status_dict,
                    })
                    last_status = status_dict

                await asyncio.sleep(2)  # Poll every 2 seconds

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"Error polling git status: {e}")
                await asyncio.sleep(5)

    try:
        while True:
            try:
                data = await websocket.receive_text()
                message = json.loads(data)
                msg_type = message.get("type")

                if msg_type == "ping":
                    await websocket.send_json({"type": "pong"})

                elif msg_type == "subscribe":
                    if not subscribed:
                        subscribed = True
                        poll_task = asyncio.create_task(poll_status())

                elif msg_type == "unsubscribe":
                    if poll_task:
                        poll_task.cancel()
                        poll_task = None
                    subscribed = False

                elif msg_type == "refresh":
                    status = await git.status()
                    await websocket.send_json({
                        "type": "status",
                        "data": {
                            "branch": status.branch,
                            "ahead": status.ahead,
                            "behind": status.behind,
                            "staged": status.staged,
                            "unstaged": status.unstaged,
                            "untracked": status.untracked,
                            "has_conflicts": status.has_conflicts,
                            "is_clean": status.is_clean,
                        },
                    })

                else:
                    await websocket.send_json({
                        "type": "error",
                        "content": f"Unknown message type: {msg_type}"
                    })

            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "content": "Invalid JSON message"
                })

    except WebSocketDisconnect:
        logger.info(f"Git WebSocket disconnected for project: {project_name}")
    except Exception as e:
        logger.exception(f"Git WebSocket error for project {project_name}: {e}")
    finally:
        if poll_task:
            poll_task.cancel()


     1→"""
     2→Git Agent
     3→=========
     4→
     5→Dedicated agent responsible for all version control operations:
     6→- Track changes made by coding agents
     7→- Create logical, well-structured commits
     8→- Push completed work to remote
     9→- Pull code when review is needed
    10→- Handle merge conflicts
    11→"""
    12→
    13→import asyncio
    14→import logging
    15→from dataclasses import dataclass, field
    16→from datetime import datetime
    17→from pathlib import Path
    18→from typing import Optional, Callable, Any
    19→
    20→from ..services.git_operations import GitOperations, GitStatus, GitResult
    21→
    22→logger = logging.getLogger(__name__)
    23→
    24→
    25→@dataclass
    26→class ChangeEntry:
    27→    """A tracked change made by a coding agent."""
    28→    file: str
    29→    agent_id: str
    30→    feature_id: int
    31→    change_type: str  # "created", "modified", "deleted"
    32→    timestamp: datetime = field(default_factory=datetime.utcnow)
    33→    summary: Optional[str] = None
    34→
    35→
    36→@dataclass
    37→class GitAgentConfig:
    38→    """Configuration for the Git Agent."""
    39→    auto_commit: bool = True
    40→    auto_push: bool = True
    41→    commit_style: str = "conventional"  # "conventional", "simple", "detailed"
    42→    branch_strategy: str = "main"  # "main", "feature"
    43→    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
    44→    pull_before: list[str] = field(default_factory=lambda: ["code_review", "feature_start"])
    45→    conflict_auto_resolve: list[str] = field(default_factory=lambda: ["whitespace", "imports"])
    46→
    47→
    48→class GitAgent:
    49→    """
    50→    Dedicated agent for version control operations.
    51→
    52→    The Git Agent:
    53→    1. Tracks changes made by coding agents during feature implementation
    54→    2. Groups related changes into logical commits
    55→    3. Generates meaningful commit messages
    56→    4. Pushes completed work to the remote repository
    57→    5. Provides code review context by pulling and summarizing changes
    58→    """
    59→
    60→    def __init__(
    61→        self,
    62→        project_dir: Path,
    63→        config: Optional[GitAgentConfig] = None,
    64→        on_commit: Optional[Callable[[str, str], Any]] = None,
    65→        on_push: Optional[Callable[[bool, str], Any]] = None,
    66→    ):
    67→        """
    68→        Initialize the Git Agent.
    69→
    70→        Args:
    71→            project_dir: Path to the project directory (git repo)
    72→            config: Agent configuration
    73→            on_commit: Callback when a commit is made (hash, message)
    74→            on_push: Callback when a push completes (success, output)
    75→        """
    76→        self.project_dir = project_dir
    77→        self.config = config or GitAgentConfig()
    78→        self.git = GitOperations(project_dir)
    79→        self.change_log: list[ChangeEntry] = []
    80→        self._on_commit = on_commit
    81→        self._on_push = on_push
    82→        self._lock = asyncio.Lock()
    83→
    84→    async def track_change(
    85→        self,
    86→        file: str,
    87→        agent_id: str,
    88→        feature_id: int,
    89→        change_type: str = "modified",
    90→        summary: Optional[str] = None,
    91→    ) -> None:
    92→        """
    93→        Record a change made by a coding agent.
    94→
    95→        This should be called by the orchestrator when a coding agent
    96→        modifies a file during feature implementation.
    97→
    98→        Args:
    99→            file: Path to the changed file (relative to project)
   100→            agent_id: ID of the coding agent that made the change
   101→            feature_id: ID of the feature being implemented
   102→            change_type: Type of change (created, modified, deleted)
   103→            summary: Optional description of the change
   104→        """
   105→        async with self._lock:
   106→            entry = ChangeEntry(
   107→                file=file,
   108→                agent_id=agent_id,
   109→                feature_id=feature_id,
   110→                change_type=change_type,
   111→                summary=summary,
   112→            )
   113→            self.change_log.append(entry)
   114→            logger.debug(f"Tracked change: {file} ({change_type}) for feature {feature_id}")
   115→
   116→    async def on_feature_complete(
   117→        self,
   118→        feature_id: int,
   119→        feature_name: Optional[str] = None,
   120→    ) -> Optional[GitResult]:
   121→        """
   122→        Handle feature completion - commit and optionally push changes.
   123→
   124→        Called by the orchestrator when a coding agent completes a feature.
   125→
   126→        Args:
   127→            feature_id: ID of the completed feature
   128→            feature_name: Optional name of the feature
   129→
   130→        Returns:
   131→            GitResult if a commit was made, None if no changes
   132→        """
   133→        if not self.config.auto_commit:
   134→            return None
   135→
   136→        async with self._lock:
   137→            # Get changes for this feature
   138→            feature_changes = [
   139→                c for c in self.change_log if c.feature_id == feature_id
   140→            ]
   141→
   142→            if not feature_changes:
   143→                logger.info(f"No tracked changes for feature {feature_id}")
   144→                return None
   145→
   146→            # Generate commit message
   147→            message = self._generate_commit_message(
   148→                feature_id, feature_name, feature_changes
   149→            )
   150→
   151→            # Get unique files
   152→            files = list(set(c.file for c in feature_changes))
   153→
   154→            # Create commit
   155→            result = await self.git.commit(message, files=files)
   156→
   157→            if result.success:
   158→                logger.info(f"Committed feature {feature_id}: {result.commit_hash}")
   159→
   160→                # Callback
   161→                if self._on_commit and result.commit_hash:
   162→                    self._on_commit(result.commit_hash, message)
   163→
   164→                # Clear tracked changes for this feature
   165→                self.change_log = [
   166→                    c for c in self.change_log if c.feature_id != feature_id
   167→                ]
   168→
   169→                # Auto-push if configured
   170→                if self.config.auto_push and "feature_complete" in self.config.push_on:
   171→                    push_result = await self.git.push()
   172→                    if self._on_push:
   173→                        self._on_push(push_result.success, push_result.output)
   174→
   175→            else:
   176→                logger.error(f"Commit failed for feature {feature_id}: {result.error}")
   177→
   178→            return result
   179→
   180→    async def on_session_end(self) -> Optional[GitResult]:
   181→        """
   182→        Handle session end - commit any remaining changes.
   183→
   184→        Called when the autocoder session ends to ensure no work is lost.
   185→
   186→        Returns:
   187→            GitResult if a commit was made, None if no changes
   188→        """
   189→        if not self.change_log:
   190→            return None
   191→
   192→        async with self._lock:
   193→            # Group remaining changes by feature
   194→            features = set(c.feature_id for c in self.change_log)
   195→
   196→            if len(features) == 1:
   197→                # Single feature - commit normally
   198→                feature_id = list(features)[0]
   199→                return await self.on_feature_complete(feature_id)
   200→            else:
   201→                # Multiple features - create a summary commit
   202→                message = self._generate_session_end_message(self.change_log)
   203→                files = list(set(c.file for c in self.change_log))
   204→
   205→                result = await self.git.commit(message, files=files)
   206→
   207→                if result.success:
   208→                    self.change_log.clear()
   209→
   210→                    if self.config.auto_push and "session_end" in self.config.push_on:
   211→                        push_result = await self.git.push()
   212→                        if self._on_push:
   213→                            self._on_push(push_result.success, push_result.output)
   214→
   215→                return result
   216→
   217→    async def provide_review_context(
   218→        self,
   219→        files: Optional[list[str]] = None,
   220→        since_commit: Optional[str] = None,
   221→    ) -> str:
   222→        """
   223→        Pull latest and provide context for code review.
   224→
   225→        Called when a coding agent needs to review existing code
   226→        before making changes.
   227→
   228→        Args:
   229→            files: Optional list of files to focus on
   230→            since_commit: Optional commit to compare from
   231→
   232→        Returns:
   233→            Formatted context string with relevant changes
   234→        """
   235→        # Pull before review if configured
   236→        if "code_review" in self.config.pull_before:
   237→            await self.git.pull()
   238→
   239→        lines = ["## Code Review Context", ""]
   240→
   241→        # Get recent commits
   242→        commits = await self.git.log(count=5)
   243→        if commits:
   244→            lines.append("### Recent Commits")
   245→            for commit in commits:
   246→                lines.append(f"- `{commit.short_hash}`: {commit.message}")
   247→            lines.append("")
   248→
   249→        # Get diff if since_commit specified
   250→        if since_commit:
   251→            changed_files = await self.git.get_changed_files_since(since_commit)
   252→            if changed_files:
   253→                lines.append(f"### Files Changed Since {since_commit[:7]}")
   254→                for f in changed_files[:20]:  # Limit to 20 files
   255→                    lines.append(f"- `{f}`")
   256→                if len(changed_files) > 20:
   257→                    lines.append(f"- ... and {len(changed_files) - 20} more")
   258→                lines.append("")
   259→
   260→        # Get current status
   261→        status = await self.git.status()
   262→        if not status.is_clean:
   263→            lines.append("### Uncommitted Changes")
   264→            if status.staged:
   265→                lines.append("**Staged:**")
   266→                for f in status.staged[:10]:
   267→                    lines.append(f"- `{f}`")
   268→            if status.unstaged:
   269→                lines.append("**Unstaged:**")
   270→                for f in status.unstaged[:10]:
   271→                    lines.append(f"- `{f}`")
   272→            lines.append("")
   273→
   274→        # Get diff for specific files if requested
   275→        if files:
   276→            lines.append("### Relevant File Changes")
   277→            for file in files[:5]:  # Limit to 5 files
   278→                diff = await self.git.diff(file=file)
   279→                if diff:
   280→                    lines.append(f"#### `{file}`")
   281→                    # Truncate long diffs
   282→                    if len(diff) > 1000:
   283→                        diff = diff[:1000] + "\n... (truncated)"
   284→                    lines.append(f"```diff\n{diff}\n```")
   285→            lines.append("")
   286→
   287→        return "\n".join(lines)
   288→
   289→    async def handle_conflict(self) -> dict:
   290→        """
   291→        Analyze and potentially resolve merge conflicts.
   292→
   293→        Returns:
   294→            Dict with conflict info and resolution status
   295→        """
   296→        status = await self.git.status()
   297→
   298→        if not status.has_conflicts:
   299→            return {"has_conflicts": False}
   300→
   301→        # Get conflicting files
   302→        conflicts = []
   303→        for file in status.unstaged:
   304→            # Check if file has conflict markers
   305→            file_path = self.project_dir / file
   306→            if file_path.exists():
   307→                try:
   308→                    content = file_path.read_text(encoding="utf-8")
   309→                    if "<<<<<<< " in content:
   310→                        conflicts.append(file)
   311→                except Exception:
   312→                    pass
   313→
   314→        return {
   315→            "has_conflicts": True,
   316→            "conflicting_files": conflicts,
   317→            "auto_resolvable": self._can_auto_resolve(conflicts),
   318→        }
   319→
   320→    def _can_auto_resolve(self, files: list[str]) -> bool:
   321→        """Check if conflicts can be auto-resolved."""
   322→        # Simple heuristic - check if conflicts are in certain file types
   323→        auto_resolve_extensions = {".lock", ".sum", ".mod"}
   324→        return all(
   325→            Path(f).suffix in auto_resolve_extensions
   326→            for f in files
   327→        )
   328→
   329→    def _generate_commit_message(
   330→        self,
   331→        feature_id: int,
   332→        feature_name: Optional[str],
   333→        changes: list[ChangeEntry],
   334→    ) -> str:
   335→        """Generate a commit message for feature changes."""
   336→        if self.config.commit_style == "simple":
   337→            return f"Implement feature #{feature_id}" + (
   338→                f": {feature_name}" if feature_name else ""
   339→            )
   340→
   341→        elif self.config.commit_style == "conventional":
   342→            # Determine type from changes
   343→            has_new_files = any(c.change_type == "created" for c in changes)
   344→            commit_type = "feat" if has_new_files else "fix"
   345→
   346→            subject = f"{commit_type}: implement feature #{feature_id}"
   347→            if feature_name:
   348→                subject = f"{commit_type}: {feature_name}"
   349→
   350→            body_lines = ["", "Changes:"]
   351→            for change in changes[:10]:  # Limit body
   352→                action = {
   353→                    "created": "Add",
   354→                    "modified": "Update",
   355→                    "deleted": "Remove",
   356→                }.get(change.change_type, "Modify")
   357→                body_lines.append(f"- {action} {change.file}")
   358→                if change.summary:
   359→                    body_lines.append(f"  {change.summary}")
   360→
   361→            if len(changes) > 10:
   362→                body_lines.append(f"- ... and {len(changes) - 10} more files")
   363→
   364→            body_lines.append("")
   365→            body_lines.append(f"Feature #{feature_id}")
   366→
   367→            return subject + "\n" + "\n".join(body_lines)
   368→
   369→        else:  # detailed
   370→            lines = [f"Feature #{feature_id}: {feature_name or 'Implementation'}"]
   371→            lines.append("")
   372→            lines.append("## Changes")
   373→            lines.append("")
   374→
   375→            for change in changes:
   376→                lines.append(f"### {change.file}")
   377→                lines.append(f"- Type: {change.change_type}")
   378→                lines.append(f"- Agent: {change.agent_id}")
   379→                if change.summary:
   380→                    lines.append(f"- Summary: {change.summary}")
   381→                lines.append("")
   382→
   383→            return "\n".join(lines)
   384→
   385→    def _generate_session_end_message(self, changes: list[ChangeEntry]) -> str:
   386→        """Generate a commit message for session end."""
   387→        feature_ids = set(c.feature_id for c in changes)
   388→        files = set(c.file for c in changes)
   389→
   390→        if len(feature_ids) == 1:
   391→            return f"WIP: Feature #{list(feature_ids)[0]} progress ({len(files)} files)"
   392→        else:
   393→            return f"WIP: Progress on features {', '.join(f'#{f}' for f in sorted(feature_ids))} ({len(files)} files)"
   394→
   395→    async def get_status(self) -> GitStatus:
   396→        """Get current git status."""
   397→        return await self.git.status()
   398→
   399→    def get_pending_changes(self) -> list[ChangeEntry]:
   400→        """Get list of tracked but uncommitted changes."""
   401→        return list(self.change_log)
   402→
   403→    def clear_tracking(self) -> None:
   404→        """Clear all tracked changes (use after manual commit)."""
   405→        self.change_log.clear()
   406→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Git Agent
=========

Dedicated agent responsible for all version control operations:
- Track changes made by coding agents
- Create logical, well-structured commits
- Push completed work to remote
- Pull code when review is needed
- Handle merge conflicts
"""

import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional, Callable, Any

from ..services.git_operations import GitOperations, GitStatus, GitResult

logger = logging.getLogger(__name__)

@dataclass
class ChangeEntry:
    """A tracked change made by a coding agent."""
    file: str
    agent_id: str
    feature_id: int
    change_type: str  # "created", "modified", "deleted"
    timestamp: datetime = field(default_factory=datetime.utcnow)
    summary: Optional[str] = None

@dataclass
class GitAgentConfig:
    """Configuration for the Git Agent."""
    auto_commit: bool = True
    auto_push: bool = True
    commit_style: str = "conventional"  # "conventional", "simple", "detailed"
    branch_strategy: str = "main"  # "main", "feature"
    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
    pull_before: list[str] = field(default_factory=lambda: ["code_review", "feature_start"])
    conflict_auto_resolve: list[str] = field(default_factory=lambda: ["whitespace", "imports"])

class GitAgent:
    """
    Dedicated agent for version control operations.

    The Git Agent:
    1. Tracks changes made by coding agents during feature implementation
    2. Groups related changes into logical commits
    3. Generates meaningful commit messages
    4. Pushes completed work to the remote repository
    5. Provides code review context by pulling and summarizing changes
    """

    def __init__(
        self,
        project_dir: Path,
        config: Optional[GitAgentConfig] = None,
        on_commit: Optional[Callable[[str, str], Any]] = None,
        on_push: Optional[Callable[[bool, str], Any]] = None,
    ):
        """
        Initialize the Git Agent.

        Args:
            project_dir: Path to the project directory (git repo)
            config: Agent configuration
            on_commit: Callback when a commit is made (hash, message)
            on_push: Callback when a push completes (success, output)
        """
        self.project_dir = project_dir
        self.config = config or GitAgentConfig()
        self.git = GitOperations(project_dir)
        self.change_log: list[ChangeEntry] = []
        self._on_commit = on_commit
        self._on_push = on_push
        self._lock = asyncio.Lock()

    async def track_change(
        self,
        file: str,
        agent_id: str,
        feature_id: int,
        change_type: str = "modified",
        summary: Optional[str] = None,
    ) -> None:
        """
        Record a change made by a coding agent.

        This should be called by the orchestrator when a coding agent
        modifies a file during feature implementation.

        Args:
            file: Path to the changed file (relative to project)
            agent_id: ID of the coding agent that made the change
            feature_id: ID of the feature being implemented
            change_type: Type of change (created, modified, deleted)
            summary: Optional description of the change
        """
        async with self._lock:
            entry = ChangeEntry(
                file=file,
                agent_id=agent_id,
                feature_id=feature_id,
                change_type=change_type,
                summary=summary,
            )
            self.change_log.append(entry)
            logger.debug(f"Tracked change: {file} ({change_type}) for feature {feature_id}")

    async def on_feature_complete(
        self,
        feature_id: int,
        feature_name: Optional[str] = None,
    ) -> Optional[GitResult]:
        """
        Handle feature completion - commit and optionally push changes.

        Called by the orchestrator when a coding agent completes a feature.

        Args:
            feature_id: ID of the completed feature
            feature_name: Optional name of the feature

        Returns:
            GitResult if a commit was made, None if no changes
        """
        if not self.config.auto_commit:
            return None

        async with self._lock:
            # Get changes for this feature
            feature_changes = [
                c for c in self.change_log if c.feature_id == feature_id
            ]

            if not feature_changes:
                logger.info(f"No tracked changes for feature {feature_id}")
                return None

            # Generate commit message
            message = self._generate_commit_message(
                feature_id, feature_name, feature_changes
            )

            # Get unique files
            files = list(set(c.file for c in feature_changes))

            # Create commit
            result = await self.git.commit(message, files=files)

            if result.success:
                logger.info(f"Committed feature {feature_id}: {result.commit_hash}")

                # Callback
                if self._on_commit and result.commit_hash:
                    self._on_commit(result.commit_hash, message)

                # Clear tracked changes for this feature
                self.change_log = [
                    c for c in self.change_log if c.feature_id != feature_id
                ]

                # Auto-push if configured
                if self.config.auto_push and "feature_complete" in self.config.push_on:
                    push_result = await self.git.push()
                    if self._on_push:
                        self._on_push(push_result.success, push_result.output)

            else:
                logger.error(f"Commit failed for feature {feature_id}: {result.error}")

            return result

    async def on_session_end(self) -> Optional[GitResult]:
        """
        Handle session end - commit any remaining changes.

        Called when the autocoder session ends to ensure no work is lost.

        Returns:
            GitResult if a commit was made, None if no changes
        """
        if not self.change_log:
            return None

        async with self._lock:
            # Group remaining changes by feature
            features = set(c.feature_id for c in self.change_log)

            if len(features) == 1:
                # Single feature - commit normally
                feature_id = list(features)[0]
                return await self.on_feature_complete(feature_id)
            else:
                # Multiple features - create a summary commit
                message = self._generate_session_end_message(self.change_log)
                files = list(set(c.file for c in self.change_log))

                result = await self.git.commit(message, files=files)

                if result.success:
                    self.change_log.clear()

                    if self.config.auto_push and "session_end" in self.config.push_on:
                        push_result = await self.git.push()
                        if self._on_push:
                            self._on_push(push_result.success, push_result.output)

                return result

    async def provide_review_context(
        self,
        files: Optional[list[str]] = None,
        since_commit: Optional[str] = None,
    ) -> str:
        """
        Pull latest and provide context for code review.

        Called when a coding agent needs to review existing code
        before making changes.

        Args:
            files: Optional list of files to focus on
            since_commit: Optional commit to compare from

        Returns:
            Formatted context string with relevant changes
        """
        # Pull before review if configured
        if "code_review" in self.config.pull_before:
            await self.git.pull()

        lines = ["## Code Review Context", ""]

        # Get recent commits
        commits = await self.git.log(count=5)
        if commits:
            lines.append("### Recent Commits")
            for commit in commits:
                lines.append(f"- `{commit.short_hash}`: {commit.message}")
            lines.append("")

        # Get diff if since_commit specified
        if since_commit:
            changed_files = await self.git.get_changed_files_since(since_commit)
            if changed_files:
                lines.append(f"### Files Changed Since {since_commit[:7]}")
                for f in changed_files[:20]:  # Limit to 20 files
                    lines.append(f"- `{f}`")
                if len(changed_files) > 20:
                    lines.append(f"- ... and {len(changed_files) - 20} more")
                lines.append("")

        # Get current status
        status = await self.git.status()
        if not status.is_clean:
            lines.append("### Uncommitted Changes")
            if status.staged:
                lines.append("**Staged:**")
                for f in status.staged[:10]:
                    lines.append(f"- `{f}`")
            if status.unstaged:
                lines.append("**Unstaged:**")
                for f in status.unstaged[:10]:
                    lines.append(f"- `{f}`")
            lines.append("")

        # Get diff for specific files if requested
        if files:
            lines.append("### Relevant File Changes")
            for file in files[:5]:  # Limit to 5 files
                diff = await self.git.diff(file=file)
                if diff:
                    lines.append(f"#### `{file}`")
                    # Truncate long diffs
                    if len(diff) > 1000:
                        diff = diff[:1000] + "\n... (truncated)"
                    lines.append(f"```diff\n{diff}\n```")
            lines.append("")

        return "\n".join(lines)

    async def handle_conflict(self) -> dict:
        """
        Analyze and potentially resolve merge conflicts.

        Returns:
            Dict with conflict info and resolution status
        """
        status = await self.git.status()

        if not status.has_conflicts:
            return {"has_conflicts": False}

        # Get conflicting files
        conflicts = []
        for file in status.unstaged:
            # Check if file has conflict markers
            file_path = self.project_dir / file
            if file_path.exists():
                try:
                    content = file_path.read_text(encoding="utf-8")
                    if "<<<<<<< " in content:
                        conflicts.append(file)
                except Exception:
                    pass

        return {
            "has_conflicts": True,
            "conflicting_files": conflicts,
            "auto_resolvable": self._can_auto_resolve(conflicts),
        }

    def _can_auto_resolve(self, files: list[str]) -> bool:
        """Check if conflicts can be auto-resolved."""
        # Simple heuristic - check if conflicts are in certain file types
        auto_resolve_extensions = {".lock", ".sum", ".mod"}
        return all(
            Path(f).suffix in auto_resolve_extensions
            for f in files
        )

    def _generate_commit_message(
        self,
        feature_id: int,
        feature_name: Optional[str],
        changes: list[ChangeEntry],
    ) -> str:
        """Generate a commit message for feature changes."""
        if self.config.commit_style == "simple":
            return f"Implement feature #{feature_id}" + (
                f": {feature_name}" if feature_name else ""
            )

        elif self.config.commit_style == "conventional":
            # Determine type from changes
            has_new_files = any(c.change_type == "created" for c in changes)
            commit_type = "feat" if has_new_files else "fix"

            subject = f"{commit_type}: implement feature #{feature_id}"
            if feature_name:
                subject = f"{commit_type}: {feature_name}"

            body_lines = ["", "Changes:"]
            for change in changes[:10]:  # Limit body
                action = {
                    "created": "Add",
                    "modified": "Update",
                    "deleted": "Remove",
                }.get(change.change_type, "Modify")
                body_lines.append(f"- {action} {change.file}")
                if change.summary:
                    body_lines.append(f"  {change.summary}")

            if len(changes) > 10:
                body_lines.append(f"- ... and {len(changes) - 10} more files")

            body_lines.append("")
            body_lines.append(f"Feature #{feature_id}")

            return subject + "\n" + "\n".join(body_lines)

        else:  # detailed
            lines = [f"Feature #{feature_id}: {feature_name or 'Implementation'}"]
            lines.append("")
            lines.append("## Changes")
            lines.append("")

            for change in changes:
                lines.append(f"### {change.file}")
                lines.append(f"- Type: {change.change_type}")
                lines.append(f"- Agent: {change.agent_id}")
                if change.summary:
                    lines.append(f"- Summary: {change.summary}")
                lines.append("")

            return "\n".join(lines)

    def _generate_session_end_message(self, changes: list[ChangeEntry]) -> str:
        """Generate a commit message for session end."""
        feature_ids = set(c.feature_id for c in changes)
        files = set(c.file for c in changes)

        if len(feature_ids) == 1:
            return f"WIP: Feature #{list(feature_ids)[0]} progress ({len(files)} files)"
        else:
            return f"WIP: Progress on features {', '.join(f'#{f}' for f in sorted(feature_ids))} ({len(files)} files)"

    async def get_status(self) -> GitStatus:
        """Get current git status."""
        return await self.git.status()

    def get_pending_changes(self) -> list[ChangeEntry]:
        """Get list of tracked but uncommitted changes."""
        return list(self.change_log)

    def clear_tracking(self) -> None:
        """Clear all tracked changes (use after manual commit)."""
        self.change_log.clear()


Now let me explore more files to understand process management and feature tracking: