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

1# type: ignore 

2""" 

3Git information collector for statusline 

4 

5""" 

6 

7import logging 

8import re 

9import subprocess 

10from dataclasses import dataclass 

11from datetime import datetime, timedelta 

12from typing import Optional 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17@dataclass 

18class GitInfo: 

19 """Git repository information""" 

20 

21 branch: str 

22 staged: int 

23 modified: int 

24 untracked: int 

25 

26 

27class GitCollector: 

28 """Collects git information from repository with 5-second caching""" 

29 

30 # Configuration constants 

31 _CACHE_TTL_SECONDS = 5 

32 _GIT_COMMAND_TIMEOUT = 2 

33 

34 # File status prefixes from git status --porcelain 

35 _STATUS_ADDED = "A" 

36 _STATUS_MODIFIED = "M" 

37 _STATUS_UNTRACKED = "??" 

38 

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) 

44 

45 def collect_git_info(self) -> GitInfo: 

46 """ 

47 Collect git information from the repository 

48 

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 

55 

56 # Run git command and parse output 

57 git_info = self._fetch_git_info() 

58 self._update_cache(git_info) 

59 return git_info 

60 

61 def _fetch_git_info(self) -> GitInfo: 

62 """ 

63 Fetch git information from command 

64 

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 ) 

75 

76 if result.returncode != 0: 

77 logger.debug(f"Git command failed: {result.stderr}") 

78 return self._create_error_info() 

79 

80 return self._parse_git_output(result.stdout) 

81 

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

88 

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 

94 

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

99 

100 def _parse_git_output(self, output: str) -> GitInfo: 

101 """ 

102 Parse git status output into GitInfo 

103 

104 Args: 

105 output: Output from 'git status -b --porcelain' 

106 

107 Returns: 

108 GitInfo with parsed data 

109 """ 

110 lines = output.strip().split("\n") 

111 

112 # Extract branch name from first line (## branch_name...) 

113 branch = self._extract_branch(lines[0] if lines else "") 

114 

115 # Count file changes from remaining lines 

116 staged, modified, untracked = self._count_changes(lines[1:]) 

117 

118 return GitInfo( 

119 branch=branch, 

120 staged=staged, 

121 modified=modified, 

122 untracked=untracked, 

123 ) 

124 

125 def _count_changes(self, lines: list) -> tuple[int, int, int]: 

126 """ 

127 Count staged, modified, and untracked files 

128 

129 Args: 

130 lines: Lines from git status output (excluding header) 

131 

132 Returns: 

133 Tuple of (staged_count, modified_count, untracked_count) 

134 """ 

135 staged = 0 

136 modified = 0 

137 untracked = 0 

138 

139 for line in lines: 

140 if not line or len(line) < 2: 

141 continue 

142 

143 status = line[:2] 

144 

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 

150 

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 

155 

156 # Check untracked files 

157 if status == self._STATUS_UNTRACKED: 

158 untracked += 1 

159 

160 return staged, modified, untracked 

161 

162 @staticmethod 

163 def _extract_branch(branch_line: str) -> str: 

164 """ 

165 Extract branch name from git status -b output 

166 

167 Format: ## branch_name...origin/branch_name 

168 

169 Args: 

170 branch_line: First line from git status -b 

171 

172 Returns: 

173 Branch name or "unknown" if parsing fails 

174 """ 

175 if not branch_line.startswith("##"): 

176 return "unknown" 

177 

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" 

181 

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 )