Coverage for src/workstack/status/collectors/git.py: 33%

61 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 09:31 -0400

1"""Git status collector.""" 

2 

3import subprocess 

4from pathlib import Path 

5 

6from workstack.core.context import WorkstackContext 

7from workstack.status.collectors.base import StatusCollector 

8from workstack.status.models.status_data import CommitInfo, GitStatus 

9 

10 

11class GitStatusCollector(StatusCollector): 

12 """Collects git repository status information.""" 

13 

14 @property 

15 def name(self) -> str: 

16 """Name identifier for this collector.""" 

17 return "git" 

18 

19 def is_available(self, ctx: WorkstackContext, worktree_path: Path) -> bool: 

20 """Check if git operations are available. 

21 

22 Args: 

23 ctx: Workstack context 

24 worktree_path: Path to worktree 

25 

26 Returns: 

27 True if worktree exists and has git 

28 """ 

29 if not worktree_path.exists(): 

30 return False 

31 

32 return True 

33 

34 def collect( 

35 self, ctx: WorkstackContext, worktree_path: Path, repo_root: Path 

36 ) -> GitStatus | None: 

37 """Collect git status information. 

38 

39 Args: 

40 ctx: Workstack context 

41 worktree_path: Path to worktree 

42 repo_root: Repository root path 

43 

44 Returns: 

45 GitStatus with repository information or None if collection fails 

46 """ 

47 branch = ctx.git_ops.get_current_branch(worktree_path) 

48 if branch is None: 

49 return None 

50 

51 # Get git status 

52 staged, modified, untracked = self._get_file_status(worktree_path) 

53 clean = len(staged) == 0 and len(modified) == 0 and len(untracked) == 0 

54 

55 # Get ahead/behind counts 

56 ahead, behind = self._get_ahead_behind(worktree_path, branch) 

57 

58 # Get recent commits 

59 recent_commits = self._get_recent_commits(worktree_path, limit=5) 

60 

61 return GitStatus( 

62 branch=branch, 

63 clean=clean, 

64 ahead=ahead, 

65 behind=behind, 

66 staged_files=staged, 

67 modified_files=modified, 

68 untracked_files=untracked, 

69 recent_commits=recent_commits, 

70 ) 

71 

72 def _get_file_status(self, cwd: Path) -> tuple[list[str], list[str], list[str]]: 

73 """Get lists of staged, modified, and untracked files. 

74 

75 Args: 

76 cwd: Working directory 

77 

78 Returns: 

79 Tuple of (staged, modified, untracked) file lists 

80 """ 

81 result = subprocess.run( 

82 ["git", "status", "--porcelain"], 

83 cwd=cwd, 

84 capture_output=True, 

85 text=True, 

86 check=True, 

87 ) 

88 

89 staged = [] 

90 modified = [] 

91 untracked = [] 

92 

93 for line in result.stdout.splitlines(): 

94 if not line: 

95 continue 

96 

97 status_code = line[:2] 

98 filename = line[3:] 

99 

100 # Check if file is staged (first character is not space) 

101 if status_code[0] != " " and status_code[0] != "?": 

102 staged.append(filename) 

103 

104 # Check if file is modified (second character is not space) 

105 if status_code[1] != " " and status_code[1] != "?": 

106 modified.append(filename) 

107 

108 # Check if file is untracked 

109 if status_code == "??": 

110 untracked.append(filename) 

111 

112 return staged, modified, untracked 

113 

114 def _get_ahead_behind(self, cwd: Path, branch: str) -> tuple[int, int]: 

115 """Get number of commits ahead and behind tracking branch. 

116 

117 Args: 

118 cwd: Working directory 

119 branch: Current branch name 

120 

121 Returns: 

122 Tuple of (ahead, behind) counts 

123 """ 

124 # Check if branch has upstream 

125 result = subprocess.run( 

126 ["git", "rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}"], 

127 cwd=cwd, 

128 capture_output=True, 

129 text=True, 

130 check=False, 

131 ) 

132 

133 if result.returncode != 0: 

134 # No upstream branch 

135 return 0, 0 

136 

137 upstream = result.stdout.strip() 

138 

139 # Get ahead/behind counts 

140 result = subprocess.run( 

141 ["git", "rev-list", "--left-right", "--count", f"{upstream}...HEAD"], 

142 cwd=cwd, 

143 capture_output=True, 

144 text=True, 

145 check=True, 

146 ) 

147 

148 parts = result.stdout.strip().split() 

149 if len(parts) == 2: 

150 behind = int(parts[0]) 

151 ahead = int(parts[1]) 

152 return ahead, behind 

153 

154 return 0, 0 

155 

156 def _get_recent_commits(self, cwd: Path, *, limit: int = 5) -> list[CommitInfo]: 

157 """Get recent commit information. 

158 

159 Args: 

160 cwd: Working directory 

161 limit: Maximum number of commits to retrieve 

162 

163 Returns: 

164 List of recent commits 

165 """ 

166 result = subprocess.run( 

167 [ 

168 "git", 

169 "log", 

170 f"-{limit}", 

171 "--format=%H%x00%s%x00%an%x00%ar", 

172 ], 

173 cwd=cwd, 

174 capture_output=True, 

175 text=True, 

176 check=True, 

177 ) 

178 

179 commits = [] 

180 for line in result.stdout.strip().split("\n"): 

181 if not line: 

182 continue 

183 

184 parts = line.split("\x00") 

185 if len(parts) == 4: 

186 commits.append( 

187 CommitInfo( 

188 sha=parts[0][:7], # Short SHA 

189 message=parts[1], 

190 author=parts[2], 

191 date=parts[3], 

192 ) 

193 ) 

194 

195 return commits