     1→"""
     2→File Lock Manager
     3→=================
     4→
     5→Async-safe file locking to prevent concurrent modifications
     6→to the same files by parallel coding agents.
     7→"""
     8→
     9→import asyncio
    10→import logging
    11→from dataclasses import dataclass
    12→from datetime import datetime, timedelta
    13→from pathlib import Path
    14→from typing import Optional
    15→
    16→logger = logging.getLogger(__name__)
    17→
    18→
    19→@dataclass
    20→class LockInfo:
    21→    """Information about a file lock."""
    22→    file_path: str
    23→    feature_id: int
    24→    agent_id: str
    25→    locked_at: datetime
    26→    expires_at: Optional[datetime] = None
    27→
    28→
    29→@dataclass
    30→class LockResult:
    31→    """Result of a lock operation."""
    32→    success: bool
    33→    message: str
    34→    lock_info: Optional[LockInfo] = None
    35→    holder_feature_id: Optional[int] = None
    36→
    37→
    38→class FileLockManager:
    39→    """
    40→    Manage file locks for parallel coding agents.
    41→
    42→    This class provides async-safe file locking to prevent multiple
    43→    agents from modifying the same files simultaneously. It supports:
    44→
    45→    - Acquiring locks on individual files or directories
    46→    - Lock timeouts to prevent deadlocks
    47→    - Force release for recovery scenarios
    48→    - Pattern-based locking (e.g., lock all files in a directory)
    49→
    50→    Thread Safety:
    51→        All operations use asyncio.Lock to ensure thread safety
    52→        in async contexts.
    53→    """
    54→
    55→    # Default lock timeout (1 hour)
    56→    DEFAULT_TIMEOUT_SECONDS = 3600
    57→
    58→    def __init__(self, project_dir: Optional[Path] = None):
    59→        """
    60→        Initialize the file lock manager.
    61→
    62→        Args:
    63→            project_dir: Optional project directory for relative path resolution
    64→        """
    65→        self.project_dir = project_dir
    66→        self._locks: dict[str, LockInfo] = {}
    67→        self._lock = asyncio.Lock()
    68→
    69→    def _normalize_path(self, file_path: str) -> str:
    70→        """Normalize a file path for consistent locking."""
    71→        # Convert to forward slashes and lowercase for consistency
    72→        normalized = file_path.replace("\\", "/").lower()
    73→
    74→        # Remove leading/trailing slashes
    75→        normalized = normalized.strip("/")
    76→
    77→        return normalized
    78→
    79→    async def acquire(
    80→        self,
    81→        file_path: str,
    82→        feature_id: int,
    83→        agent_id: str = "default",
    84→        timeout_seconds: Optional[int] = None,
    85→        wait: bool = False,
    86→        wait_timeout: float = 30.0,
    87→    ) -> LockResult:
    88→        """
    89→        Acquire a lock on a file.
    90→
    91→        Args:
    92→            file_path: Path to the file to lock (relative to project)
    93→            feature_id: ID of the feature requesting the lock
    94→            agent_id: ID of the agent requesting the lock
    95→            timeout_seconds: Lock timeout in seconds (None for default)
    96→            wait: If True, wait for the lock to become available
    97→            wait_timeout: Maximum time to wait for the lock (if wait=True)
    98→
    99→        Returns:
   100→            LockResult indicating success or failure
   101→        """
   102→        normalized = self._normalize_path(file_path)
   103→        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
   104→
   105→        start_time = datetime.now()
   106→
   107→        while True:
   108→            async with self._lock:
   109→                # Check if already locked
   110→                existing = self._locks.get(normalized)
   111→
   112→                if existing:
   113→                    # Check if lock has expired
   114→                    if existing.expires_at and datetime.now() > existing.expires_at:
   115→                        logger.info(
   116→                            f"Lock on {normalized} expired, releasing "
   117→                            f"(was held by feature {existing.feature_id})"
   118→                        )
   119→                        del self._locks[normalized]
   120→                        existing = None
   121→
   122→                if existing:
   123→                    # Already locked by same feature - extend the lock
   124→                    if existing.feature_id == feature_id:
   125→                        existing.expires_at = datetime.now() + timedelta(seconds=timeout)
   126→                        logger.debug(f"Extended lock on {normalized} for feature {feature_id}")
   127→                        return LockResult(
   128→                            success=True,
   129→                            message="Lock extended",
   130→                            lock_info=existing,
   131→                        )
   132→
   133→                    # Locked by different feature
   134→                    if not wait:
   135→                        return LockResult(
   136→                            success=False,
   137→                            message=f"File locked by feature {existing.feature_id}",
   138→                            holder_feature_id=existing.feature_id,
   139→                        )
   140→                else:
   141→                    # Lock is available - acquire it
   142→                    lock_info = LockInfo(
   143→                        file_path=normalized,
   144→                        feature_id=feature_id,
   145→                        agent_id=agent_id,
   146→                        locked_at=datetime.now(),
   147→                        expires_at=datetime.now() + timedelta(seconds=timeout),
   148→                    )
   149→                    self._locks[normalized] = lock_info
   150→
   151→                    logger.debug(f"Acquired lock on {normalized} for feature {feature_id}")
   152→                    return LockResult(
   153→                        success=True,
   154→                        message="Lock acquired",
   155→                        lock_info=lock_info,
   156→                    )
   157→
   158→            # If waiting, check timeout and retry
   159→            if wait:
   160→                elapsed = (datetime.now() - start_time).total_seconds()
   161→                if elapsed >= wait_timeout:
   162→                    return LockResult(
   163→                        success=False,
   164→                        message=f"Timeout waiting for lock on {normalized}",
   165→                        holder_feature_id=existing.feature_id if existing else None,
   166→                    )
   167→
   168→                # Wait a bit before retrying
   169→                await asyncio.sleep(0.5)
   170→            else:
   171→                break
   172→
   173→        # Should not reach here
   174→        return LockResult(success=False, message="Unexpected error")
   175→
   176→    async def acquire_multiple(
   177→        self,
   178→        file_paths: list[str],
   179→        feature_id: int,
   180→        agent_id: str = "default",
   181→        timeout_seconds: Optional[int] = None,
   182→    ) -> LockResult:
   183→        """
   184→        Acquire locks on multiple files atomically.
   185→
   186→        Either all locks are acquired or none are. This prevents
   187→        partial lock scenarios that could lead to deadlocks.
   188→
   189→        Args:
   190→            file_paths: List of file paths to lock
   191→            feature_id: ID of the feature requesting the locks
   192→            agent_id: ID of the agent requesting the locks
   193→            timeout_seconds: Lock timeout in seconds
   194→
   195→        Returns:
   196→            LockResult indicating success or failure
   197→        """
   198→        if not file_paths:
   199→            return LockResult(success=True, message="No files to lock")
   200→
   201→        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
   202→        normalized_paths = [self._normalize_path(p) for p in file_paths]
   203→
   204→        async with self._lock:
   205→            # First, check if all locks are available
   206→            conflicts = []
   207→            for path in normalized_paths:
   208→                existing = self._locks.get(path)
   209→                if existing:
   210→                    # Check expiration
   211→                    if existing.expires_at and datetime.now() > existing.expires_at:
   212→                        continue  # Expired, will be released
   213→
   214→                    if existing.feature_id != feature_id:
   215→                        conflicts.append((path, existing.feature_id))
   216→
   217→            if conflicts:
   218→                conflict_msg = ", ".join(
   219→                    f"{p} (feature {fid})" for p, fid in conflicts
   220→                )
   221→                return LockResult(
   222→                    success=False,
   223→                    message=f"Conflicts: {conflict_msg}",
   224→                    holder_feature_id=conflicts[0][1],
   225→                )
   226→
   227→            # All locks available - acquire them
   228→            lock_time = datetime.now()
   229→            expires_at = lock_time + timedelta(seconds=timeout)
   230→
   231→            for path in normalized_paths:
   232→                # Clean up expired locks
   233→                existing = self._locks.get(path)
   234→                if existing and existing.expires_at and datetime.now() > existing.expires_at:
   235→                    del self._locks[path]
   236→
   237→                # Acquire lock
   238→                self._locks[path] = LockInfo(
   239→                    file_path=path,
   240→                    feature_id=feature_id,
   241→                    agent_id=agent_id,
   242→                    locked_at=lock_time,
   243→                    expires_at=expires_at,
   244→                )
   245→
   246→            logger.debug(
   247→                f"Acquired {len(normalized_paths)} locks for feature {feature_id}"
   248→            )
   249→            return LockResult(
   250→                success=True,
   251→                message=f"Acquired {len(normalized_paths)} locks",
   252→            )
   253→
   254→    async def release(
   255→        self,
   256→        file_path: str,
   257→        feature_id: int,
   258→    ) -> LockResult:
   259→        """
   260→        Release a lock on a file.
   261→
   262→        Args:
   263→            file_path: Path to the file to unlock
   264→            feature_id: ID of the feature releasing the lock
   265→
   266→        Returns:
   267→            LockResult indicating success or failure
   268→        """
   269→        normalized = self._normalize_path(file_path)
   270→
   271→        async with self._lock:
   272→            existing = self._locks.get(normalized)
   273→
   274→            if not existing:
   275→                return LockResult(
   276→                    success=True,
   277→                    message="Lock not held (already released)",
   278→                )
   279→
   280→            if existing.feature_id != feature_id:
   281→                return LockResult(
   282→                    success=False,
   283→                    message=f"Lock held by different feature ({existing.feature_id})",
   284→                    holder_feature_id=existing.feature_id,
   285→                )
   286→
   287→            del self._locks[normalized]
   288→            logger.debug(f"Released lock on {normalized} by feature {feature_id}")
   289→
   290→            return LockResult(
   291→                success=True,
   292→                message="Lock released",
   293→            )
   294→
   295→    async def release_all(
   296→        self,
   297→        feature_id: int,
   298→    ) -> int:
   299→        """
   300→        Release all locks held by a feature.
   301→
   302→        Args:
   303→            feature_id: ID of the feature
   304→
   305→        Returns:
   306→            Number of locks released
   307→        """
   308→        async with self._lock:
   309→            to_release = [
   310→                path for path, info in self._locks.items()
   311→                if info.feature_id == feature_id
   312→            ]
   313→
   314→            for path in to_release:
   315→                del self._locks[path]
   316→
   317→            if to_release:
   318→                logger.debug(
   319→                    f"Released {len(to_release)} locks for feature {feature_id}"
   320→                )
   321→
   322→            return len(to_release)
   323→
   324→    async def force_release(
   325→        self,
   326→        file_path: str,
   327→    ) -> LockResult:
   328→        """
   329→        Force release a lock regardless of owner.
   330→
   331→        Use with caution - this is for recovery scenarios only.
   332→
   333→        Args:
   334→            file_path: Path to the file to unlock
   335→
   336→        Returns:
   337→            LockResult indicating success
   338→        """
   339→        normalized = self._normalize_path(file_path)
   340→
   341→        async with self._lock:
   342→            existing = self._locks.get(normalized)
   343→
   344→            if existing:
   345→                logger.warning(
   346→                    f"Force releasing lock on {normalized} "
   347→                    f"(was held by feature {existing.feature_id})"
   348→                )
   349→                del self._locks[normalized]
   350→                return LockResult(
   351→                    success=True,
   352→                    message=f"Force released (was held by feature {existing.feature_id})",
   353→                )
   354→
   355→            return LockResult(
   356→                success=True,
   357→                message="Lock not held",
   358→            )
   359→
   360→    async def is_locked(
   361→        self,
   362→        file_path: str,
   363→    ) -> tuple[bool, Optional[int]]:
   364→        """
   365→        Check if a file is locked.
   366→
   367→        Args:
   368→            file_path: Path to check
   369→
   370→        Returns:
   371→            Tuple of (is_locked, feature_id or None)
   372→        """
   373→        normalized = self._normalize_path(file_path)
   374→
   375→        async with self._lock:
   376→            existing = self._locks.get(normalized)
   377→
   378→            if existing:
   379→                # Check expiration
   380→                if existing.expires_at and datetime.now() > existing.expires_at:
   381→                    del self._locks[normalized]
   382→                    return False, None
   383→
   384→                return True, existing.feature_id
   385→
   386→            return False, None
   387→
   388→    async def get_locks_for_feature(
   389→        self,
   390→        feature_id: int,
   391→    ) -> list[LockInfo]:
   392→        """
   393→        Get all locks held by a feature.
   394→
   395→        Args:
   396→            feature_id: ID of the feature
   397→
   398→        Returns:
   399→            List of lock information
   400→        """
   401→        async with self._lock:
   402→            return [
   403→                info for info in self._locks.values()
   404→                if info.feature_id == feature_id
   405→            ]
   406→
   407→    async def get_all_locks(self) -> dict[str, LockInfo]:
   408→        """
   409→        Get all current locks.
   410→
   411→        Returns:
   412→            Dictionary mapping file paths to lock information
   413→        """
   414→        async with self._lock:
   415→            # Clean up expired locks first
   416→            now = datetime.now()
   417→            expired = [
   418→                path for path, info in self._locks.items()
   419→                if info.expires_at and now > info.expires_at
   420→            ]
   421→            for path in expired:
   422→                del self._locks[path]
   423→
   424→            return dict(self._locks)
   425→
   426→    async def cleanup_expired(self) -> int:
   427→        """
   428→        Clean up all expired locks.
   429→
   430→        Returns:
   431→            Number of locks cleaned up
   432→        """
   433→        async with self._lock:
   434→            now = datetime.now()
   435→            expired = [
   436→                path for path, info in self._locks.items()
   437→                if info.expires_at and now > info.expires_at
   438→            ]
   439→
   440→            for path in expired:
   441→                logger.debug(f"Cleaning up expired lock: {path}")
   442→                del self._locks[path]
   443→
   444→            return len(expired)
   445→
   446→    def get_stats(self) -> dict:
   447→        """
   448→        Get lock manager statistics (sync version for quick checks).
   449→
   450→        Returns:
   451→            Dictionary with lock statistics
   452→        """
   453→        return {
   454→            "total_locks": len(self._locks),
   455→            "locked_files": list(self._locks.keys()),
   456→            "features_with_locks": list(set(
   457→                info.feature_id for info in self._locks.values()
   458→            )),
   459→        }
   460→
   461→
   462→# Global lock manager instance
   463→_global_lock_manager: Optional[FileLockManager] = None
   464→
   465→
   466→def get_lock_manager(project_dir: Optional[Path] = None) -> FileLockManager:
   467→    """
   468→    Get or create the global lock manager.
   469→
   470→    Args:
   471→        project_dir: Project directory (used on first call)
   472→
   473→    Returns:
   474→        The global FileLockManager instance
   475→    """
   476→    global _global_lock_manager
   477→
   478→    if _global_lock_manager is None:
   479→        _global_lock_manager = FileLockManager(project_dir)
   480→
   481→    return _global_lock_manager
   482→
   483→
   484→def reset_lock_manager() -> None:
   485→    """Reset the global lock manager (for testing)."""
   486→    global _global_lock_manager
   487→    _global_lock_manager = None
   488→

<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>

"""
File Lock Manager
=================

Async-safe file locking to prevent concurrent modifications
to the same files by parallel coding agents.
"""

import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

@dataclass
class LockInfo:
    """Information about a file lock."""
    file_path: str
    feature_id: int
    agent_id: str
    locked_at: datetime
    expires_at: Optional[datetime] = None

@dataclass
class LockResult:
    """Result of a lock operation."""
    success: bool
    message: str
    lock_info: Optional[LockInfo] = None
    holder_feature_id: Optional[int] = None

class FileLockManager:
    """
    Manage file locks for parallel coding agents.

    This class provides async-safe file locking to prevent multiple
    agents from modifying the same files simultaneously. It supports:

    - Acquiring locks on individual files or directories
    - Lock timeouts to prevent deadlocks
    - Force release for recovery scenarios
    - Pattern-based locking (e.g., lock all files in a directory)

    Thread Safety:
        All operations use asyncio.Lock to ensure thread safety
        in async contexts.
    """

    # Default lock timeout (1 hour)
    DEFAULT_TIMEOUT_SECONDS = 3600

    def __init__(self, project_dir: Optional[Path] = None):
        """
        Initialize the file lock manager.

        Args:
            project_dir: Optional project directory for relative path resolution
        """
        self.project_dir = project_dir
        self._locks: dict[str, LockInfo] = {}
        self._lock = asyncio.Lock()

    def _normalize_path(self, file_path: str) -> str:
        """Normalize a file path for consistent locking."""
        # Convert to forward slashes and lowercase for consistency
        normalized = file_path.replace("\\", "/").lower()

        # Remove leading/trailing slashes
        normalized = normalized.strip("/")

        return normalized

    async def acquire(
        self,
        file_path: str,
        feature_id: int,
        agent_id: str = "default",
        timeout_seconds: Optional[int] = None,
        wait: bool = False,
        wait_timeout: float = 30.0,
    ) -> LockResult:
        """
        Acquire a lock on a file.

        Args:
            file_path: Path to the file to lock (relative to project)
            feature_id: ID of the feature requesting the lock
            agent_id: ID of the agent requesting the lock
            timeout_seconds: Lock timeout in seconds (None for default)
            wait: If True, wait for the lock to become available
            wait_timeout: Maximum time to wait for the lock (if wait=True)

        Returns:
            LockResult indicating success or failure
        """
        normalized = self._normalize_path(file_path)
        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS

        start_time = datetime.now()

        while True:
            async with self._lock:
                # Check if already locked
                existing = self._locks.get(normalized)

                if existing:
                    # Check if lock has expired
                    if existing.expires_at and datetime.now() > existing.expires_at:
                        logger.info(
                            f"Lock on {normalized} expired, releasing "
                            f"(was held by feature {existing.feature_id})"
                        )
                        del self._locks[normalized]
                        existing = None

                if existing:
                    # Already locked by same feature - extend the lock
                    if existing.feature_id == feature_id:
                        existing.expires_at = datetime.now() + timedelta(seconds=timeout)
                        logger.debug(f"Extended lock on {normalized} for feature {feature_id}")
                        return LockResult(
                            success=True,
                            message="Lock extended",
                            lock_info=existing,
                        )

                    # Locked by different feature
                    if not wait:
                        return LockResult(
                            success=False,
                            message=f"File locked by feature {existing.feature_id}",
                            holder_feature_id=existing.feature_id,
                        )
                else:
                    # Lock is available - acquire it
                    lock_info = LockInfo(
                        file_path=normalized,
                        feature_id=feature_id,
                        agent_id=agent_id,
                        locked_at=datetime.now(),
                        expires_at=datetime.now() + timedelta(seconds=timeout),
                    )
                    self._locks[normalized] = lock_info

                    logger.debug(f"Acquired lock on {normalized} for feature {feature_id}")
                    return LockResult(
                        success=True,
                        message="Lock acquired",
                        lock_info=lock_info,
                    )

            # If waiting, check timeout and retry
            if wait:
                elapsed = (datetime.now() - start_time).total_seconds()
                if elapsed >= wait_timeout:
                    return LockResult(
                        success=False,
                        message=f"Timeout waiting for lock on {normalized}",
                        holder_feature_id=existing.feature_id if existing else None,
                    )

                # Wait a bit before retrying
                await asyncio.sleep(0.5)
            else:
                break

        # Should not reach here
        return LockResult(success=False, message="Unexpected error")

    async def acquire_multiple(
        self,
        file_paths: list[str],
        feature_id: int,
        agent_id: str = "default",
        timeout_seconds: Optional[int] = None,
    ) -> LockResult:
        """
        Acquire locks on multiple files atomically.

        Either all locks are acquired or none are. This prevents
        partial lock scenarios that could lead to deadlocks.

        Args:
            file_paths: List of file paths to lock
            feature_id: ID of the feature requesting the locks
            agent_id: ID of the agent requesting the locks
            timeout_seconds: Lock timeout in seconds

        Returns:
            LockResult indicating success or failure
        """
        if not file_paths:
            return LockResult(success=True, message="No files to lock")

        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
        normalized_paths = [self._normalize_path(p) for p in file_paths]

        async with self._lock:
            # First, check if all locks are available
            conflicts = []
            for path in normalized_paths:
                existing = self._locks.get(path)
                if existing:
                    # Check expiration
                    if existing.expires_at and datetime.now() > existing.expires_at:
                        continue  # Expired, will be released

                    if existing.feature_id != feature_id:
                        conflicts.append((path, existing.feature_id))

            if conflicts:
                conflict_msg = ", ".join(
                    f"{p} (feature {fid})" for p, fid in conflicts
                )
                return LockResult(
                    success=False,
                    message=f"Conflicts: {conflict_msg}",
                    holder_feature_id=conflicts[0][1],
                )

            # All locks available - acquire them
            lock_time = datetime.now()
            expires_at = lock_time + timedelta(seconds=timeout)

            for path in normalized_paths:
                # Clean up expired locks
                existing = self._locks.get(path)
                if existing and existing.expires_at and datetime.now() > existing.expires_at:
                    del self._locks[path]

                # Acquire lock
                self._locks[path] = LockInfo(
                    file_path=path,
                    feature_id=feature_id,
                    agent_id=agent_id,
                    locked_at=lock_time,
                    expires_at=expires_at,
                )

            logger.debug(
                f"Acquired {len(normalized_paths)} locks for feature {feature_id}"
            )
            return LockResult(
                success=True,
                message=f"Acquired {len(normalized_paths)} locks",
            )

    async def release(
        self,
        file_path: str,
        feature_id: int,
    ) -> LockResult:
        """
        Release a lock on a file.

        Args:
            file_path: Path to the file to unlock
            feature_id: ID of the feature releasing the lock

        Returns:
            LockResult indicating success or failure
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if not existing:
                return LockResult(
                    success=True,
                    message="Lock not held (already released)",
                )

            if existing.feature_id != feature_id:
                return LockResult(
                    success=False,
                    message=f"Lock held by different feature ({existing.feature_id})",
                    holder_feature_id=existing.feature_id,
                )

            del self._locks[normalized]
            logger.debug(f"Released lock on {normalized} by feature {feature_id}")

            return LockResult(
                success=True,
                message="Lock released",
            )

    async def release_all(
        self,
        feature_id: int,
    ) -> int:
        """
        Release all locks held by a feature.

        Args:
            feature_id: ID of the feature

        Returns:
            Number of locks released
        """
        async with self._lock:
            to_release = [
                path for path, info in self._locks.items()
                if info.feature_id == feature_id
            ]

            for path in to_release:
                del self._locks[path]

            if to_release:
                logger.debug(
                    f"Released {len(to_release)} locks for feature {feature_id}"
                )

            return len(to_release)

    async def force_release(
        self,
        file_path: str,
    ) -> LockResult:
        """
        Force release a lock regardless of owner.

        Use with caution - this is for recovery scenarios only.

        Args:
            file_path: Path to the file to unlock

        Returns:
            LockResult indicating success
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if existing:
                logger.warning(
                    f"Force releasing lock on {normalized} "
                    f"(was held by feature {existing.feature_id})"
                )
                del self._locks[normalized]
                return LockResult(
                    success=True,
                    message=f"Force released (was held by feature {existing.feature_id})",
                )

            return LockResult(
                success=True,
                message="Lock not held",
            )

    async def is_locked(
        self,
        file_path: str,
    ) -> tuple[bool, Optional[int]]:
        """
        Check if a file is locked.

        Args:
            file_path: Path to check

        Returns:
            Tuple of (is_locked, feature_id or None)
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if existing:
                # Check expiration
                if existing.expires_at and datetime.now() > existing.expires_at:
                    del self._locks[normalized]
                    return False, None

                return True, existing.feature_id

            return False, None

    async def get_locks_for_feature(
        self,
        feature_id: int,
    ) -> list[LockInfo]:
        """
        Get all locks held by a feature.

        Args:
            feature_id: ID of the feature

        Returns:
            List of lock information
        """
        async with self._lock:
            return [
                info for info in self._locks.values()
                if info.feature_id == feature_id
            ]

    async def get_all_locks(self) -> dict[str, LockInfo]:
        """
        Get all current locks.

        Returns:
            Dictionary mapping file paths to lock information
        """
        async with self._lock:
            # Clean up expired locks first
            now = datetime.now()
            expired = [
                path for path, info in self._locks.items()
                if info.expires_at and now > info.expires_at
            ]
            for path in expired:
                del self._locks[path]

            return dict(self._locks)

    async def cleanup_expired(self) -> int:
        """
        Clean up all expired locks.

        Returns:
            Number of locks cleaned up
        """
        async with self._lock:
            now = datetime.now()
            expired = [
                path for path, info in self._locks.items()
                if info.expires_at and now > info.expires_at
            ]

            for path in expired:
                logger.debug(f"Cleaning up expired lock: {path}")
                del self._locks[path]

            return len(expired)

    def get_stats(self) -> dict:
        """
        Get lock manager statistics (sync version for quick checks).

        Returns:
            Dictionary with lock statistics
        """
        return {
            "total_locks": len(self._locks),
            "locked_files": list(self._locks.keys()),
            "features_with_locks": list(set(
                info.feature_id for info in self._locks.values()
            )),
        }

# Global lock manager instance
_global_lock_manager: Optional[FileLockManager] = None

def get_lock_manager(project_dir: Optional[Path] = None) -> FileLockManager:
    """
    Get or create the global lock manager.

    Args:
        project_dir: Project directory (used on first call)

    Returns:
        The global FileLockManager instance
    """
    global _global_lock_manager

    if _global_lock_manager is None:
        _global_lock_manager = FileLockManager(project_dir)

    return _global_lock_manager

def reset_lock_manager() -> None:
    """Reset the global lock manager (for testing)."""
    global _global_lock_manager
    _global_lock_manager = None


     1→"""
     2→Git Operations Service
     3→======================
     4→
     5→Low-level git operations for the Git Agent and UI.
     6→Provides async wrappers around git commands.
     7→"""
     8→
     9→import asyncio
    10→import re
    11→from dataclasses import dataclass, field
    12→from datetime import datetime
    13→from pathlib import Path
    14→from typing import Optional
    15→
    16→
    17→@dataclass
    18→class GitStatus:
    19→    """Git repository status."""
    20→    branch: str
    21→    ahead: int = 0
    22→    behind: int = 0
    23→    staged: list[str] = field(default_factory=list)
    24→    unstaged: list[str] = field(default_factory=list)
    25→    untracked: list[str] = field(default_factory=list)
    26→    has_conflicts: bool = False
    27→    is_clean: bool = True
    28→    remote: Optional[str] = None
    29→    last_commit_hash: Optional[str] = None
    30→    last_commit_message: Optional[str] = None
    31→    last_commit_time: Optional[str] = None
    32→
    33→
    34→@dataclass
    35→class GitCommit:
    36→    """A git commit."""
    37→    hash: str
    38→    short_hash: str
    39→    message: str
    40→    author: str
    41→    date: str
    42→    files_changed: int = 0
    43→
    44→
    45→@dataclass
    46→class GitResult:
    47→    """Result of a git operation."""
    48→    success: bool
    49→    output: str
    50→    error: Optional[str] = None
    51→    commit_hash: Optional[str] = None
    52→
    53→
    54→class GitOperations:
    55→    """
    56→    Low-level git operations.
    57→
    58→    All operations are async and work on a specific project directory.
    59→    """
    60→
    61→    def __init__(self, project_dir: Path):
    62→        """
    63→        Initialize git operations for a project.
    64→
    65→        Args:
    66→            project_dir: Path to the git repository
    67→        """
    68→        self.project_dir = project_dir
    69→
    70→    async def _run_git(
    71→        self,
    72→        *args: str,
    73→        check: bool = False,
    74→        timeout: float = 30.0,
    75→    ) -> tuple[int, str, str]:
    76→        """
    77→        Run a git command.
    78→
    79→        Args:
    80→            *args: Git command arguments
    81→            check: If True, raise on non-zero exit
    82→            timeout: Command timeout in seconds
    83→
    84→        Returns:
    85→            Tuple of (return_code, stdout, stderr)
    86→        """
    87→        cmd = ["git", *args]
    88→
    89→        try:
    90→            process = await asyncio.create_subprocess_exec(
    91→                *cmd,
    92→                cwd=str(self.project_dir),
    93→                stdout=asyncio.subprocess.PIPE,
    94→                stderr=asyncio.subprocess.PIPE,
    95→            )
    96→
    97→            stdout, stderr = await asyncio.wait_for(
    98→                process.communicate(),
    99→                timeout=timeout,
   100→            )
   101→
   102→            stdout_str = stdout.decode("utf-8", errors="replace").strip()
   103→            stderr_str = stderr.decode("utf-8", errors="replace").strip()
   104→
   105→            if check and process.returncode != 0:
   106→                raise RuntimeError(f"Git command failed: {stderr_str}")
   107→
   108→            return process.returncode or 0, stdout_str, stderr_str
   109→
   110→        except asyncio.TimeoutError:
   111→            raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
   112→
   113→    async def is_repo(self) -> bool:
   114→        """Check if the project directory is a git repository."""
   115→        returncode, _, _ = await self._run_git("rev-parse", "--git-dir")
   116→        return returncode == 0
   117→
   118→    async def status(self) -> GitStatus:
   119→        """
   120→        Get detailed git status.
   121→
   122→        Returns:
   123→            GitStatus with branch, file states, and remote info
   124→        """
   125→        # Get branch name
   126→        returncode, branch, _ = await self._run_git("rev-parse", "--abbrev-ref", "HEAD")
   127→        if returncode != 0:
   128→            branch = "unknown"
   129→
   130→        # Get status in porcelain format
   131→        _, status_output, _ = await self._run_git("status", "--porcelain=v1", "-b")
   132→
   133→        staged = []
   134→        unstaged = []
   135→        untracked = []
   136→        has_conflicts = False
   137→        ahead = 0
   138→        behind = 0
   139→
   140→        lines = status_output.split("\n")
   141→        for line in lines:
   142→            if not line:
   143→                continue
   144→
   145→            # First line with -b flag shows branch info
   146→            if line.startswith("##"):
   147→                # Parse ahead/behind from "## main...origin/main [ahead 1, behind 2]"
   148→                match = re.search(r"\[ahead (\d+)", line)
   149→                if match:
   150→                    ahead = int(match.group(1))
   151→                match = re.search(r"behind (\d+)", line)
   152→                if match:
   153→                    behind = int(match.group(1))
   154→                continue
   155→
   156→            # Parse file status
   157→            if len(line) >= 3:
   158→                index_status = line[0]
   159→                work_status = line[1]
   160→                filename = line[3:]
   161→
   162→                # Untracked
   163→                if index_status == "?" and work_status == "?":
   164→                    untracked.append(filename)
   165→                # Conflicts
   166→                elif index_status == "U" or work_status == "U":
   167→                    has_conflicts = True
   168→                    unstaged.append(filename)
   169→                else:
   170→                    # Staged changes
   171→                    if index_status in ("A", "M", "D", "R", "C"):
   172→                        staged.append(filename)
   173→                    # Unstaged changes
   174→                    if work_status in ("M", "D"):
   175→                        unstaged.append(filename)
   176→
   177→        is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0
   178→
   179→        # Get remote
   180→        _, remote, _ = await self._run_git("config", "--get", "remote.origin.url")
   181→
   182→        # Get last commit info
   183→        _, commit_info, _ = await self._run_git(
   184→            "log", "-1", "--format=%H|%h|%s|%an|%ai"
   185→        )
   186→        last_hash = None
   187→        last_message = None
   188→        last_time = None
   189→        if commit_info and "|" in commit_info:
   190→            parts = commit_info.split("|")
   191→            if len(parts) >= 5:
   192→                last_hash = parts[1]  # short hash
   193→                last_message = parts[2]
   194→                last_time = parts[4]
   195→
   196→        return GitStatus(
   197→            branch=branch,
   198→            ahead=ahead,
   199→            behind=behind,
   200→            staged=staged,
   201→            unstaged=unstaged,
   202→            untracked=untracked,
   203→            has_conflicts=has_conflicts,
   204→            is_clean=is_clean,
   205→            remote=remote or None,
   206→            last_commit_hash=last_hash,
   207→            last_commit_message=last_message,
   208→            last_commit_time=last_time,
   209→        )
   210→
   211→    async def diff(self, staged: bool = False, file: Optional[str] = None) -> str:
   212→        """
   213→        Get diff of changes.
   214→
   215→        Args:
   216→            staged: If True, show staged changes; otherwise show unstaged
   217→            file: Optional specific file to diff
   218→
   219→        Returns:
   220→            Diff output as string
   221→        """
   222→        args = ["diff"]
   223→        if staged:
   224→            args.append("--cached")
   225→        if file:
   226→            args.extend(["--", file])
   227→
   228→        _, output, _ = await self._run_git(*args)
   229→        return output
   230→
   231→    async def log(
   232→        self,
   233→        count: int = 10,
   234→        file: Optional[str] = None,
   235→        since: Optional[str] = None,
   236→    ) -> list[GitCommit]:
   237→        """
   238→        Get commit history.
   239→
   240→        Args:
   241→            count: Number of commits to retrieve
   242→            file: Optional file to filter commits
   243→            since: Optional date filter (e.g., "2024-01-01")
   244→
   245→        Returns:
   246→            List of GitCommit objects
   247→        """
   248→        args = [
   249→            "log",
   250→            f"-{count}",
   251→            "--format=%H|%h|%s|%an|%ai|%stat",
   252→        ]
   253→
   254→        if since:
   255→            args.append(f"--since={since}")
   256→        if file:
   257→            args.extend(["--", file])
   258→
   259→        _, output, _ = await self._run_git(*args)
   260→
   261→        commits = []
   262→        for line in output.split("\n"):
   263→            if not line or "|" not in line:
   264→                continue
   265→
   266→            parts = line.split("|")
   267→            if len(parts) >= 5:
   268→                commits.append(
   269→                    GitCommit(
   270→                        hash=parts[0],
   271→                        short_hash=parts[1],
   272→                        message=parts[2],
   273→                        author=parts[3],
   274→                        date=parts[4],
   275→                    )
   276→                )
   277→
   278→        return commits
   279→
   280→    async def add(self, files: Optional[list[str]] = None) -> GitResult:
   281→        """
   282→        Stage files for commit.
   283→
   284→        Args:
   285→            files: List of files to stage, or None for all
   286→
   287→        Returns:
   288→            GitResult with operation status
   289→        """
   290→        if files:
   291→            args = ["add", "--"] + files
   292→        else:
   293→            args = ["add", "-A"]
   294→
   295→        returncode, output, error = await self._run_git(*args)
   296→
   297→        return GitResult(
   298→            success=returncode == 0,
   299→            output=output,
   300→            error=error if returncode != 0 else None,
   301→        )
   302→
   303→    async def commit(
   304→        self,
   305→        message: str,
   306→        files: Optional[list[str]] = None,
   307→        amend: bool = False,
   308→    ) -> GitResult:
   309→        """
   310→        Create a commit.
   311→
   312→        Args:
   313→            message: Commit message
   314→            files: Optional specific files to commit (will be staged first)
   315→            amend: If True, amend the previous commit
   316→
   317→        Returns:
   318→            GitResult with commit hash on success
   319→        """
   320→        # Stage files if specified
   321→        if files:
   322→            add_result = await self.add(files)
   323→            if not add_result.success:
   324→                return add_result
   325→
   326→        # Build commit command
   327→        args = ["commit", "-m", message]
   328→        if amend:
   329→            args.append("--amend")
   330→
   331→        returncode, output, error = await self._run_git(*args)
   332→
   333→        # Extract commit hash from output
   334→        commit_hash = None
   335→        if returncode == 0:
   336→            # Output typically contains "[branch hash] message"
   337→            match = re.search(r"\[[\w/-]+ ([a-f0-9]+)\]", output)
   338→            if match:
   339→                commit_hash = match.group(1)
   340→
   341→        return GitResult(
   342→            success=returncode == 0,
   343→            output=output,
   344→            error=error if returncode != 0 else None,
   345→            commit_hash=commit_hash,
   346→        )
   347→
   348→    async def push(
   349→        self,
   350→        remote: str = "origin",
   351→        branch: Optional[str] = None,
   352→        force: bool = False,
   353→        set_upstream: bool = False,
   354→    ) -> GitResult:
   355→        """
   356→        Push commits to remote.
   357→
   358→        Args:
   359→            remote: Remote name (default: origin)
   360→            branch: Branch name (default: current branch)
   361→            force: Force push
   362→            set_upstream: Set upstream tracking
   363→
   364→        Returns:
   365→            GitResult with operation status
   366→        """
   367→        args = ["push", remote]
   368→
   369→        if branch:
   370→            args.append(branch)
   371→        if force:
   372→            args.append("--force")
   373→        if set_upstream:
   374→            args.append("--set-upstream")
   375→
   376→        returncode, output, error = await self._run_git(*args, timeout=60.0)
   377→
   378→        return GitResult(
   379→            success=returncode == 0,
   380→            output=output or error,  # Git often puts push info in stderr
   381→            error=error if returncode != 0 else None,
   382→        )
   383→
   384→    async def pull(
   385→        self,
   386→        remote: str = "origin",
   387→        branch: Optional[str] = None,
   388→        rebase: bool = False,
   389→    ) -> GitResult:
   390→        """
   391→        Pull changes from remote.
   392→
   393→        Args:
   394→            remote: Remote name (default: origin)
   395→            branch: Branch name (default: current branch)
   396→            rebase: Use rebase instead of merge
   397→
   398→        Returns:
   399→            GitResult with operation status
   400→        """
   401→        args = ["pull", remote]
   402→
   403→        if branch:
   404→            args.append(branch)
   405→        if rebase:
   406→            args.append("--rebase")
   407→
   408→        returncode, output, error = await self._run_git(*args, timeout=60.0)
   409→
   410→        return GitResult(
   411→            success=returncode == 0,
   412→            output=output,
   413→            error=error if returncode != 0 else None,
   414→        )
   415→
   416→    async def fetch(self, remote: str = "origin") -> GitResult:
   417→        """
   418→        Fetch changes from remote without merging.
   419→
   420→        Args:
   421→            remote: Remote name (default: origin)
   422→
   423→        Returns:
   424→            GitResult with operation status
   425→        """
   426→        returncode, output, error = await self._run_git("fetch", remote, timeout=60.0)
   427→
   428→        return GitResult(
   429→            success=returncode == 0,
   430→            output=output,
   431→            error=error if returncode != 0 else None,
   432→        )
   433→
   434→    async def checkout(
   435→        self,
   436→        branch: str,
   437→        create: bool = False,
   438→    ) -> GitResult:
   439→        """
   440→        Checkout a branch.
   441→
   442→        Args:
   443→            branch: Branch name
   444→            create: Create the branch if it doesn't exist
   445→
   446→        Returns:
   447→            GitResult with operation status
   448→        """
   449→        args = ["checkout"]
   450→        if create:
   451→            args.append("-b")
   452→        args.append(branch)
   453→
   454→        returncode, output, error = await self._run_git(*args)
   455→
   456→        return GitResult(
   457→            success=returncode == 0,
   458→            output=output,
   459→            error=error if returncode != 0 else None,
   460→        )
   461→
   462→    async def create_branch(self, name: str, start_point: Optional[str] = None) -> GitResult:
   463→        """
   464→        Create a new branch.
   465→
   466→        Args:
   467→            name: Branch name
   468→            start_point: Optional starting commit/branch
   469→
   470→        Returns:
   471→            GitResult with operation status
   472→        """
   473→        args = ["branch", name]
   474→        if start_point:
   475→            args.append(start_point)
   476→
   477→        returncode, output, error = await self._run_git(*args)
   478→
   479→        return GitResult(
   480→            success=returncode == 0,
   481→            output=output,
   482→            error=error if returncode != 0 else None,
   483→        )
   484→
   485→    async def stash(self, message: Optional[str] = None) -> GitResult:
   486→        """
   487→        Stash current changes.
   488→
   489→        Args:
   490→            message: Optional stash message
   491→
   492→        Returns:
   493→            GitResult with operation status
   494→        """
   495→        args = ["stash", "push"]
   496→        if message:
   497→            args.extend(["-m", message])
   498→
   499→        returncode, output, error = await self._run_git(*args)
   500→
   501→        return GitResult(
   502→            success=returncode == 0,
   503→            output=output,
   504→            error=error if returncode != 0 else None,
   505→        )
   506→
   507→    async def stash_pop(self) -> GitResult:
   508→        """
   509→        Pop the most recent stash.
   510→
   511→        Returns:
   512→            GitResult with operation status
   513→        """
   514→        returncode, output, error = await self._run_git("stash", "pop")
   515→
   516→        return GitResult(
   517→            success=returncode == 0,
   518→            output=output,
   519→            error=error if returncode != 0 else None,
   520→        )
   521→
   522→    async def reset(
   523→        self,
   524→        mode: str = "mixed",
   525→        commit: str = "HEAD",
   526→    ) -> GitResult:
   527→        """
   528→        Reset repository state.
   529→
   530→        Args:
   531→            mode: Reset mode (soft, mixed, hard)
   532→            commit: Target commit (default: HEAD)
   533→
   534→        Returns:
   535→            GitResult with operation status
   536→        """
   537→        args = ["reset", f"--{mode}", commit]
   538→        returncode, output, error = await self._run_git(*args)
   539→
   540→        return GitResult(
   541→            success=returncode == 0,
   542→            output=output,
   543→            error=error if returncode != 0 else None,
   544→        )
   545→
   546→    async def get_changed_files_since(self, commit: str) -> list[str]:
   547→        """
   548→        Get list of files changed since a specific commit.
   549→
   550→        Args:
   551→            commit: Starting commit hash
   552→
   553→        Returns:
   554→            List of changed file paths
   555→        """
   556→        _, output, _ = await self._run_git(
   557→            "diff", "--name-only", f"{commit}..HEAD"
   558→        )
   559→
   560→        return [f for f in output.split("\n") if f]
   561→
   562→    async def get_file_at_commit(self, file: str, commit: str) -> str:
   563→        """
   564→        Get file contents at a specific commit.
   565→
   566→        Args:
   567→            file: File path
   568→            commit: Commit hash
   569→
   570→        Returns:
   571→            File contents as string
   572→        """
   573→        _, output, _ = await self._run_git("show", f"{commit}:{file}")
   574→        return output
   575→

<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 Operations Service
======================

Low-level git operations for the Git Agent and UI.
Provides async wrappers around git commands.
"""

import asyncio
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional

@dataclass
class GitStatus:
    """Git repository status."""
    branch: str
    ahead: int = 0
    behind: int = 0
    staged: list[str] = field(default_factory=list)
    unstaged: list[str] = field(default_factory=list)
    untracked: list[str] = field(default_factory=list)
    has_conflicts: bool = False
    is_clean: bool = True
    remote: Optional[str] = None
    last_commit_hash: Optional[str] = None
    last_commit_message: Optional[str] = None
    last_commit_time: Optional[str] = None

@dataclass
class GitCommit:
    """A git commit."""
    hash: str
    short_hash: str
    message: str
    author: str
    date: str
    files_changed: int = 0

@dataclass
class GitResult:
    """Result of a git operation."""
    success: bool
    output: str
    error: Optional[str] = None
    commit_hash: Optional[str] = None

class GitOperations:
    """
    Low-level git operations.

    All operations are async and work on a specific project directory.
    """

    def __init__(self, project_dir: Path):
        """
        Initialize git operations for a project.

        Args:
            project_dir: Path to the git repository
        """
        self.project_dir = project_dir

    async def _run_git(
        self,
        *args: str,
        check: bool = False,
        timeout: float = 30.0,
    ) -> tuple[int, str, str]:
        """
        Run a git command.

        Args:
            *args: Git command arguments
            check: If True, raise on non-zero exit
            timeout: Command timeout in seconds

        Returns:
            Tuple of (return_code, stdout, stderr)
        """
        cmd = ["git", *args]

        try:
            process = await asyncio.create_subprocess_exec(
                *cmd,
                cwd=str(self.project_dir),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            stdout, stderr = await asyncio.wait_for(
                process.communicate(),
                timeout=timeout,
            )

            stdout_str = stdout.decode("utf-8", errors="replace").strip()
            stderr_str = stderr.decode("utf-8", errors="replace").strip()

            if check and process.returncode != 0:
                raise RuntimeError(f"Git command failed: {stderr_str}")

            return process.returncode or 0, stdout_str, stderr_str

        except asyncio.TimeoutError:
            raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")

    async def is_repo(self) -> bool:
        """Check if the project directory is a git repository."""
        returncode, _, _ = await self._run_git("rev-parse", "--git-dir")
        return returncode == 0

    async def status(self) -> GitStatus:
        """
        Get detailed git status.

        Returns:
            GitStatus with branch, file states, and remote info
        """
        # Get branch name
        returncode, branch, _ = await self._run_git("rev-parse", "--abbrev-ref", "HEAD")
        if returncode != 0:
            branch = "unknown"

        # Get status in porcelain format
        _, status_output, _ = await self._run_git("status", "--porcelain=v1", "-b")

        staged = []
        unstaged = []
        untracked = []
        has_conflicts = False
        ahead = 0
        behind = 0

        lines = status_output.split("\n")
        for line in lines:
            if not line:
                continue

            # First line with -b flag shows branch info
            if line.startswith("##"):
                # Parse ahead/behind from "## main...origin/main [ahead 1, behind 2]"
                match = re.search(r"\[ahead (\d+)", line)
                if match:
                    ahead = int(match.group(1))
                match = re.search(r"behind (\d+)", line)
                if match:
                    behind = int(match.group(1))
                continue

            # Parse file status
            if len(line) >= 3:
                index_status = line[0]
                work_status = line[1]
                filename = line[3:]

                # Untracked
                if index_status == "?" and work_status == "?":
                    untracked.append(filename)
                # Conflicts
                elif index_status == "U" or work_status == "U":
                    has_conflicts = True
                    unstaged.append(filename)
                else:
                    # Staged changes
                    if index_status in ("A", "M", "D", "R", "C"):
                        staged.append(filename)
                    # Unstaged changes
                    if work_status in ("M", "D"):
                        unstaged.append(filename)

        is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0

        # Get remote
        _, remote, _ = await self._run_git("config", "--get", "remote.origin.url")

        # Get last commit info
        _, commit_info, _ = await self._run_git(
            "log", "-1", "--format=%H|%h|%s|%an|%ai"
        )
        last_hash = None
        last_message = None
        last_time = None
        if commit_info and "|" in commit_info:
            parts = commit_info.split("|")
            if len(parts) >= 5:
                last_hash = parts[1]  # short hash
                last_message = parts[2]
                last_time = parts[4]

        return GitStatus(
            branch=branch,
            ahead=ahead,
            behind=behind,
            staged=staged,
            unstaged=unstaged,
            untracked=untracked,
            has_conflicts=has_conflicts,
            is_clean=is_clean,
            remote=remote or None,
            last_commit_hash=last_hash,
            last_commit_message=last_message,
            last_commit_time=last_time,
        )

    async def diff(self, staged: bool = False, file: Optional[str] = None) -> str:
        """
        Get diff of changes.

        Args:
            staged: If True, show staged changes; otherwise show unstaged
            file: Optional specific file to diff

        Returns:
            Diff output as string
        """
        args = ["diff"]
        if staged:
            args.append("--cached")
        if file:
            args.extend(["--", file])

        _, output, _ = await self._run_git(*args)
        return output

    async def log(
        self,
        count: int = 10,
        file: Optional[str] = None,
        since: Optional[str] = None,
    ) -> list[GitCommit]:
        """
        Get commit history.

        Args:
            count: Number of commits to retrieve
            file: Optional file to filter commits
            since: Optional date filter (e.g., "2024-01-01")

        Returns:
            List of GitCommit objects
        """
        args = [
            "log",
            f"-{count}",
            "--format=%H|%h|%s|%an|%ai|%stat",
        ]

        if since:
            args.append(f"--since={since}")
        if file:
            args.extend(["--", file])

        _, output, _ = await self._run_git(*args)

        commits = []
        for line in output.split("\n"):
            if not line or "|" not in line:
                continue

            parts = line.split("|")
            if len(parts) >= 5:
                commits.append(
                    GitCommit(
                        hash=parts[0],
                        short_hash=parts[1],
                        message=parts[2],
                        author=parts[3],
                        date=parts[4],
                    )
                )

        return commits

    async def add(self, files: Optional[list[str]] = None) -> GitResult:
        """
        Stage files for commit.

        Args:
            files: List of files to stage, or None for all

        Returns:
            GitResult with operation status
        """
        if files:
            args = ["add", "--"] + files
        else:
            args = ["add", "-A"]

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def commit(
        self,
        message: str,
        files: Optional[list[str]] = None,
        amend: bool = False,
    ) -> GitResult:
        """
        Create a commit.

        Args:
            message: Commit message
            files: Optional specific files to commit (will be staged first)
            amend: If True, amend the previous commit

        Returns:
            GitResult with commit hash on success
        """
        # Stage files if specified
        if files:
            add_result = await self.add(files)
            if not add_result.success:
                return add_result

        # Build commit command
        args = ["commit", "-m", message]
        if amend:
            args.append("--amend")

        returncode, output, error = await self._run_git(*args)

        # Extract commit hash from output
        commit_hash = None
        if returncode == 0:
            # Output typically contains "[branch hash] message"
            match = re.search(r"\[[\w/-]+ ([a-f0-9]+)\]", output)
            if match:
                commit_hash = match.group(1)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
            commit_hash=commit_hash,
        )

    async def push(
        self,
        remote: str = "origin",
        branch: Optional[str] = None,
        force: bool = False,
        set_upstream: bool = False,
    ) -> GitResult:
        """
        Push commits to remote.

        Args:
            remote: Remote name (default: origin)
            branch: Branch name (default: current branch)
            force: Force push
            set_upstream: Set upstream tracking

        Returns:
            GitResult with operation status
        """
        args = ["push", remote]

        if branch:
            args.append(branch)
        if force:
            args.append("--force")
        if set_upstream:
            args.append("--set-upstream")

        returncode, output, error = await self._run_git(*args, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output or error,  # Git often puts push info in stderr
            error=error if returncode != 0 else None,
        )

    async def pull(
        self,
        remote: str = "origin",
        branch: Optional[str] = None,
        rebase: bool = False,
    ) -> GitResult:
        """
        Pull changes from remote.

        Args:
            remote: Remote name (default: origin)
            branch: Branch name (default: current branch)
            rebase: Use rebase instead of merge

        Returns:
            GitResult with operation status
        """
        args = ["pull", remote]

        if branch:
            args.append(branch)
        if rebase:
            args.append("--rebase")

        returncode, output, error = await self._run_git(*args, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def fetch(self, remote: str = "origin") -> GitResult:
        """
        Fetch changes from remote without merging.

        Args:
            remote: Remote name (default: origin)

        Returns:
            GitResult with operation status
        """
        returncode, output, error = await self._run_git("fetch", remote, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def checkout(
        self,
        branch: str,
        create: bool = False,
    ) -> GitResult:
        """
        Checkout a branch.

        Args:
            branch: Branch name
            create: Create the branch if it doesn't exist

        Returns:
            GitResult with operation status
        """
        args = ["checkout"]
        if create:
            args.append("-b")
        args.append(branch)

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def create_branch(self, name: str, start_point: Optional[str] = None) -> GitResult:
        """
        Create a new branch.

        Args:
            name: Branch name
            start_point: Optional starting commit/branch

        Returns:
            GitResult with operation status
        """
        args = ["branch", name]
        if start_point:
            args.append(start_point)

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def stash(self, message: Optional[str] = None) -> GitResult:
        """
        Stash current changes.

        Args:
            message: Optional stash message

        Returns:
            GitResult with operation status
        """
        args = ["stash", "push"]
        if message:
            args.extend(["-m", message])

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def stash_pop(self) -> GitResult:
        """
        Pop the most recent stash.

        Returns:
            GitResult with operation status
        """
        returncode, output, error = await self._run_git("stash", "pop")

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def reset(
        self,
        mode: str = "mixed",
        commit: str = "HEAD",
    ) -> GitResult:
        """
        Reset repository state.

        Args:
            mode: Reset mode (soft, mixed, hard)
            commit: Target commit (default: HEAD)

        Returns:
            GitResult with operation status
        """
        args = ["reset", f"--{mode}", commit]
        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def get_changed_files_since(self, commit: str) -> list[str]:
        """
        Get list of files changed since a specific commit.

        Args:
            commit: Starting commit hash

        Returns:
            List of changed file paths
        """
        _, output, _ = await self._run_git(
            "diff", "--name-only", f"{commit}..HEAD"
        )

        return [f for f in output.split("\n") if f]

    async def get_file_at_commit(self, file: str, commit: str) -> str:
        """
        Get file contents at a specific commit.

        Args:
            file: File path
            commit: Commit hash

        Returns:
            File contents as string
        """
        _, output, _ = await self._run_git("show", f"{commit}:{file}")
        return output


Now let me read the remaining service files: