Coverage for src / moai_adk / statusline / git_collector.py: 34.21%
76 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1# type: ignore
2"""
3Git information collector for statusline
5"""
7import logging
8import re
9import subprocess
10from dataclasses import dataclass
11from datetime import datetime, timedelta
12from typing import Optional
14logger = logging.getLogger(__name__)
17@dataclass
18class GitInfo:
19 """Git repository information"""
21 branch: str
22 staged: int
23 modified: int
24 untracked: int
27class GitCollector:
28 """Collects git information from repository with 5-second caching"""
30 # Configuration constants
31 _CACHE_TTL_SECONDS = 5
32 _GIT_COMMAND_TIMEOUT = 2
34 # File status prefixes from git status --porcelain
35 _STATUS_ADDED = "A"
36 _STATUS_MODIFIED = "M"
37 _STATUS_UNTRACKED = "??"
39 def __init__(self):
40 """Initialize git collector with cache"""
41 self._cache: Optional[GitInfo] = None
42 self._cache_time: Optional[datetime] = None
43 self._cache_ttl = timedelta(seconds=self._CACHE_TTL_SECONDS)
45 def collect_git_info(self) -> GitInfo:
46 """
47 Collect git information from the repository
49 Returns:
50 GitInfo containing branch name and change counts
51 """
52 # Check cache validity first
53 if self._is_cache_valid():
54 return self._cache
56 # Run git command and parse output
57 git_info = self._fetch_git_info()
58 self._update_cache(git_info)
59 return git_info
61 def _fetch_git_info(self) -> GitInfo:
62 """
63 Fetch git information from command
65 Returns:
66 GitInfo from command or error defaults
67 """
68 try:
69 result = subprocess.run(
70 ["git", "status", "-b", "--porcelain"],
71 capture_output=True,
72 text=True,
73 timeout=self._GIT_COMMAND_TIMEOUT,
74 )
76 if result.returncode != 0:
77 logger.debug(f"Git command failed: {result.stderr}")
78 return self._create_error_info()
80 return self._parse_git_output(result.stdout)
82 except subprocess.TimeoutExpired:
83 logger.warning("Git command timed out")
84 return self._create_error_info()
85 except Exception as e:
86 logger.debug(f"Error collecting git info: {e}")
87 return self._create_error_info()
89 def _is_cache_valid(self) -> bool:
90 """Check if cache is still valid"""
91 if self._cache is None or self._cache_time is None:
92 return False
93 return datetime.now() - self._cache_time < self._cache_ttl
95 def _update_cache(self, git_info: GitInfo) -> None:
96 """Update cache with new git info"""
97 self._cache = git_info
98 self._cache_time = datetime.now()
100 def _parse_git_output(self, output: str) -> GitInfo:
101 """
102 Parse git status output into GitInfo
104 Args:
105 output: Output from 'git status -b --porcelain'
107 Returns:
108 GitInfo with parsed data
109 """
110 lines = output.strip().split("\n")
112 # Extract branch name from first line (## branch_name...)
113 branch = self._extract_branch(lines[0] if lines else "")
115 # Count file changes from remaining lines
116 staged, modified, untracked = self._count_changes(lines[1:])
118 return GitInfo(
119 branch=branch,
120 staged=staged,
121 modified=modified,
122 untracked=untracked,
123 )
125 def _count_changes(self, lines: list) -> tuple[int, int, int]:
126 """
127 Count staged, modified, and untracked files
129 Args:
130 lines: Lines from git status output (excluding header)
132 Returns:
133 Tuple of (staged_count, modified_count, untracked_count)
134 """
135 staged = 0
136 modified = 0
137 untracked = 0
139 for line in lines:
140 if not line or len(line) < 2:
141 continue
143 status = line[:2]
145 # Check staged changes (first character)
146 if status[0] == self._STATUS_ADDED:
147 staged += 1
148 elif status[0] == self._STATUS_MODIFIED:
149 staged += 1
151 # Check unstaged/working directory changes (second character)
152 # Detects: M(modified), D(deleted), A(added), R(renamed), C(copied), T(type changed)
153 if len(status) > 1 and status[1] not in (" ", "."):
154 modified += 1
156 # Check untracked files
157 if status == self._STATUS_UNTRACKED:
158 untracked += 1
160 return staged, modified, untracked
162 @staticmethod
163 def _extract_branch(branch_line: str) -> str:
164 """
165 Extract branch name from git status -b output
167 Format: ## branch_name...origin/branch_name
169 Args:
170 branch_line: First line from git status -b
172 Returns:
173 Branch name or "unknown" if parsing fails
174 """
175 if not branch_line.startswith("##"):
176 return "unknown"
178 # Use regex to extract branch name before first dot
179 match = re.match(r"##\s+([^\s\.]+)", branch_line)
180 return match.group(1) if match else "unknown"
182 @staticmethod
183 def _create_error_info() -> GitInfo:
184 """Create error info with default values for graceful degradation"""
185 return GitInfo(
186 branch="unknown",
187 staged=0,
188 modified=0,
189 untracked=0,
190 )