fancygit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fancygit-1.0.0.dist-info/METADATA +169 -0
- fancygit-1.0.0.dist-info/RECORD +29 -0
- fancygit-1.0.0.dist-info/WHEEL +5 -0
- fancygit-1.0.0.dist-info/entry_points.txt +2 -0
- fancygit-1.0.0.dist-info/top_level.txt +2 -0
- src/__init__.py +1 -0
- src/colors.py +260 -0
- src/git_error.py +55 -0
- src/git_error_parser.py +43 -0
- src/git_insights.py +304 -0
- src/git_runner.py +20 -0
- src/loading_animation.py +167 -0
- src/merge_conflict.py +27 -0
- src/mermaid_export.py +430 -0
- src/ollama_client.py +142 -0
- src/output_colorizer.py +358 -0
- src/repo_state.py +29 -0
- src/utils.py +0 -0
- tests/README.md +186 -0
- tests/__init__.py +0 -0
- tests/conftest.py +61 -0
- tests/test_conflict_parser_integration.py +65 -0
- tests/test_fancygit_advanced.py +504 -0
- tests/test_fancygit_commands.py +507 -0
- tests/test_fancygit_integration.py +158 -0
- tests/test_fancygit_workflows.py +441 -0
- tests/test_git_error.py +74 -0
- tests/test_git_error_parser.py +129 -0
- tests/test_git_runner.py +118 -0
src/output_colorizer.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
FancyGit Output Colorizer
|
|
4
|
+
Intelligently colors git command outputs based on content and context
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
from src.colors import Colors, color_success, color_error, color_warning, color_info, color_file, color_branch, color_header
|
|
10
|
+
|
|
11
|
+
class OutputColorizer:
|
|
12
|
+
"""Intelligent output coloring for git commands"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
# Git status patterns
|
|
16
|
+
self.status_patterns = {
|
|
17
|
+
'modified': r'^\s*modified:',
|
|
18
|
+
'new_file': r'^\s*new file:',
|
|
19
|
+
'deleted': r'^\s*deleted:',
|
|
20
|
+
'renamed': r'^\s*renamed:',
|
|
21
|
+
'copied': r'^\s*copied:',
|
|
22
|
+
'untracked': r'^\s*untracked:',
|
|
23
|
+
'branch': r'^On branch |^Your branch is|^Your branch and |^nothing to commit',
|
|
24
|
+
'changes': r'^Changes not staged for commit|^Changes to be committed',
|
|
25
|
+
'conflict': r'^\s*both |^\s*<<<<<<< |^\s======= |^\s>>>>>>> ',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Git log patterns
|
|
29
|
+
self.log_patterns = {
|
|
30
|
+
'commit_hash': r'^commit [a-f0-9]{40}',
|
|
31
|
+
'author': r'^Author:',
|
|
32
|
+
'date': r'^Date:',
|
|
33
|
+
'merge': r'^Merge:',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Git diff patterns
|
|
37
|
+
self.diff_patterns = {
|
|
38
|
+
'addition': r'^\+',
|
|
39
|
+
'deletion': r'^-',
|
|
40
|
+
'file_header': r'^diff --git|^index |^@@ ',
|
|
41
|
+
'context': r'^ ',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Git branch patterns
|
|
45
|
+
self.branch_patterns = {
|
|
46
|
+
'current': r'^\* ',
|
|
47
|
+
'remote': r'^ remotes/',
|
|
48
|
+
'local': r'^ ',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# General patterns
|
|
52
|
+
self.general_patterns = {
|
|
53
|
+
'error': r'^error:|^fatal:|Failed|Cannot|Unable',
|
|
54
|
+
'warning': r'^warning:|^hint:',
|
|
55
|
+
'success': r'^Successfully|^Already up-to-date|^Everything up-to-date',
|
|
56
|
+
'info': r'^Note:|^INFO:|^hint:',
|
|
57
|
+
'file_path': r'[/\\][\w\-./\\]+\.?\w*',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def colorize_output(self, command: str, stdout: str, stderr: str) -> Tuple[str, str]:
|
|
61
|
+
"""Colorize command output based on command type and content"""
|
|
62
|
+
|
|
63
|
+
if stdout:
|
|
64
|
+
stdout = self._colorize_by_command(command, stdout)
|
|
65
|
+
|
|
66
|
+
if stderr:
|
|
67
|
+
stderr = self._colorize_stderr(stderr)
|
|
68
|
+
|
|
69
|
+
return stdout, stderr
|
|
70
|
+
|
|
71
|
+
def _colorize_by_command(self, command: str, output: str) -> str:
|
|
72
|
+
"""Route to appropriate colorizer based on command"""
|
|
73
|
+
|
|
74
|
+
if command == 'status':
|
|
75
|
+
return self._colorize_status(output)
|
|
76
|
+
elif command == 'log':
|
|
77
|
+
return self._colorize_log(output)
|
|
78
|
+
elif command in ['diff', 'show']:
|
|
79
|
+
return self._colorize_diff(output)
|
|
80
|
+
elif command == 'branch':
|
|
81
|
+
return self._colorize_branch(output)
|
|
82
|
+
elif command in ['add', 'rm', 'mv']:
|
|
83
|
+
return self._colorize_file_operations(output)
|
|
84
|
+
elif command == 'commit':
|
|
85
|
+
return self._colorize_commit(output)
|
|
86
|
+
elif command in ['push', 'pull', 'fetch']:
|
|
87
|
+
return self._colorize_remote(output)
|
|
88
|
+
else:
|
|
89
|
+
return self._colorize_generic(output)
|
|
90
|
+
|
|
91
|
+
def _colorize_status(self, output: str) -> str:
|
|
92
|
+
"""Colorize git status output"""
|
|
93
|
+
lines = output.split('\n')
|
|
94
|
+
colored_lines = []
|
|
95
|
+
in_untracked_section = False
|
|
96
|
+
|
|
97
|
+
for line in lines:
|
|
98
|
+
colored_line = line
|
|
99
|
+
|
|
100
|
+
# Branch information
|
|
101
|
+
if re.search(r'^On branch ', line):
|
|
102
|
+
branch_name = line.replace('On branch ', '').strip()
|
|
103
|
+
colored_line = f"On branch {color_branch(branch_name, True)}"
|
|
104
|
+
elif re.search(r'^Your branch is ahead', line):
|
|
105
|
+
colored_line = color_info(line)
|
|
106
|
+
elif re.search(r'^Your branch is behind', line):
|
|
107
|
+
colored_line = color_warning(line)
|
|
108
|
+
elif re.search(r'^Your branch and .* have diverged', line):
|
|
109
|
+
colored_line = color_warning(line)
|
|
110
|
+
elif re.search(r'^nothing to commit', line):
|
|
111
|
+
colored_line = color_success(line)
|
|
112
|
+
|
|
113
|
+
# Section headers
|
|
114
|
+
elif re.search(r'^Changes to be committed', line):
|
|
115
|
+
colored_line = color_header(line)
|
|
116
|
+
elif re.search(r'^Changes not staged for commit', line):
|
|
117
|
+
colored_line = color_warning(line)
|
|
118
|
+
elif re.search(r'^Untracked files', line):
|
|
119
|
+
colored_line = color_info(line)
|
|
120
|
+
in_untracked_section = True
|
|
121
|
+
|
|
122
|
+
# File status - untracked files come FIRST (before general file pattern)
|
|
123
|
+
elif in_untracked_section and re.search(r'^\t(.+)$', line):
|
|
124
|
+
# File paths under Untracked files section
|
|
125
|
+
file_path = line.strip() # Remove tab and get just the filename
|
|
126
|
+
colored_line = f"\t{color_file(file_path)}"
|
|
127
|
+
elif in_untracked_section and re.search(r'^(\s{8}|\t)untracked:', line):
|
|
128
|
+
match = re.search(r'untracked:\s*(.+)', line)
|
|
129
|
+
if match:
|
|
130
|
+
file_path = match.group(1)
|
|
131
|
+
# Preserve original indentation
|
|
132
|
+
indent = line[:line.index('untracked:')]
|
|
133
|
+
colored_line = f"{indent}untracked: {color_file(file_path)}"
|
|
134
|
+
else:
|
|
135
|
+
colored_line = Colors.muted(line)
|
|
136
|
+
|
|
137
|
+
# General file pattern - LAST RESORT
|
|
138
|
+
elif re.search(r'^(\s{8}|\t)(new file: |modified: |deleted: |renamed: )(.+)$', line):
|
|
139
|
+
# Any other file-like line with indentation
|
|
140
|
+
match = re.search(r'^(\s{8}|\t)(new file: |modified: |deleted: |renamed: )(.+)$', line)
|
|
141
|
+
if match:
|
|
142
|
+
file_path = match.group(3)
|
|
143
|
+
# Preserve original indentation and file status
|
|
144
|
+
indent = line[:line.index(match.group(2))]
|
|
145
|
+
colored_line = f"{indent}{match.group(2)}{color_file(file_path)}"
|
|
146
|
+
|
|
147
|
+
colored_lines.append(colored_line)
|
|
148
|
+
|
|
149
|
+
return '\n'.join(colored_lines)
|
|
150
|
+
|
|
151
|
+
def _colorize_log(self, output: str) -> str:
|
|
152
|
+
"""Colorize git log output"""
|
|
153
|
+
lines = output.split('\n')
|
|
154
|
+
colored_lines = []
|
|
155
|
+
|
|
156
|
+
for line in lines:
|
|
157
|
+
colored_line = line
|
|
158
|
+
|
|
159
|
+
if re.search(r'^commit [a-f0-9]{40}', line):
|
|
160
|
+
# Color commit hash
|
|
161
|
+
hash_match = re.search(r'(commit )([a-f0-9]{40})', line)
|
|
162
|
+
if hash_match:
|
|
163
|
+
colored_line = f"{hash_match.group(1)}{color_info(hash_match.group(2))}"
|
|
164
|
+
elif re.search(r'^Author:', line):
|
|
165
|
+
colored_line = color_info(line)
|
|
166
|
+
elif re.search(r'^Date:', line):
|
|
167
|
+
colored_line = Colors.muted(line)
|
|
168
|
+
elif re.search(r'^Merge:', line):
|
|
169
|
+
colored_line = color_header(line)
|
|
170
|
+
|
|
171
|
+
colored_lines.append(colored_line)
|
|
172
|
+
|
|
173
|
+
return '\n'.join(colored_lines)
|
|
174
|
+
|
|
175
|
+
def _colorize_diff(self, output: str) -> str:
|
|
176
|
+
"""Colorize git diff output"""
|
|
177
|
+
lines = output.split('\n')
|
|
178
|
+
colored_lines = []
|
|
179
|
+
|
|
180
|
+
for line in lines:
|
|
181
|
+
colored_line = line
|
|
182
|
+
|
|
183
|
+
if line.startswith('+++') or line.startswith('---'):
|
|
184
|
+
# File paths
|
|
185
|
+
colored_line = color_file(line)
|
|
186
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
187
|
+
# Added lines
|
|
188
|
+
colored_line = color_success(line)
|
|
189
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
190
|
+
# Deleted lines
|
|
191
|
+
colored_line = color_error(line)
|
|
192
|
+
elif line.startswith('@@'):
|
|
193
|
+
# Line numbers
|
|
194
|
+
colored_line = color_info(line)
|
|
195
|
+
elif line.startswith('diff --git'):
|
|
196
|
+
colored_line = color_header(line)
|
|
197
|
+
elif line.startswith('index '):
|
|
198
|
+
colored_line = Colors.muted(line)
|
|
199
|
+
|
|
200
|
+
colored_lines.append(colored_line)
|
|
201
|
+
|
|
202
|
+
return '\n'.join(colored_lines)
|
|
203
|
+
|
|
204
|
+
def _colorize_branch(self, output: str) -> str:
|
|
205
|
+
"""Colorize git branch output"""
|
|
206
|
+
lines = output.split('\n')
|
|
207
|
+
colored_lines = []
|
|
208
|
+
|
|
209
|
+
for line in lines:
|
|
210
|
+
colored_line = line
|
|
211
|
+
|
|
212
|
+
if line.startswith('* '):
|
|
213
|
+
# Current branch
|
|
214
|
+
branch_name = line[2:].strip()
|
|
215
|
+
colored_line = f"* {color_branch(branch_name, True)}"
|
|
216
|
+
elif line.startswith(' remotes/'):
|
|
217
|
+
# Remote branches
|
|
218
|
+
branch_name = line.replace(' remotes/', '').strip()
|
|
219
|
+
colored_line = f" {color_info(branch_name)} (remote)"
|
|
220
|
+
elif line.startswith(' '):
|
|
221
|
+
# Local branches
|
|
222
|
+
branch_name = line[2:].strip()
|
|
223
|
+
colored_line = f" {color_branch(branch_name, False)}"
|
|
224
|
+
|
|
225
|
+
colored_lines.append(colored_line)
|
|
226
|
+
|
|
227
|
+
return '\n'.join(colored_lines)
|
|
228
|
+
|
|
229
|
+
def _colorize_file_operations(self, output: str) -> str:
|
|
230
|
+
"""Colorize file operation outputs (add, rm, mv)"""
|
|
231
|
+
lines = output.split('\n')
|
|
232
|
+
colored_lines = []
|
|
233
|
+
|
|
234
|
+
for line in lines:
|
|
235
|
+
colored_line = line
|
|
236
|
+
|
|
237
|
+
# Look for file paths in the output
|
|
238
|
+
file_matches = re.findall(r'[/\\][\w\-./\\]+\.?\w*', line)
|
|
239
|
+
for match in file_matches:
|
|
240
|
+
colored_line = colored_line.replace(match, color_file(match))
|
|
241
|
+
|
|
242
|
+
# Color success messages
|
|
243
|
+
if re.search(r'added|removed|renamed', line, re.IGNORECASE):
|
|
244
|
+
colored_line = color_success(line)
|
|
245
|
+
|
|
246
|
+
colored_lines.append(colored_line)
|
|
247
|
+
|
|
248
|
+
return '\n'.join(colored_lines)
|
|
249
|
+
|
|
250
|
+
def _colorize_commit(self, output: str) -> str:
|
|
251
|
+
"""Colorize git commit output"""
|
|
252
|
+
lines = output.split('\n')
|
|
253
|
+
colored_lines = []
|
|
254
|
+
|
|
255
|
+
for line in lines:
|
|
256
|
+
colored_line = line
|
|
257
|
+
|
|
258
|
+
if re.search(r'\[master [a-f0-9]+\]|\[main [a-f0-9]+\]|\[develop [a-f0-9]+\]', line):
|
|
259
|
+
# Branch and commit info
|
|
260
|
+
colored_line = color_success(line)
|
|
261
|
+
elif re.search(r'files? changed', line, re.IGNORECASE):
|
|
262
|
+
colored_line = color_info(line)
|
|
263
|
+
elif re.search(r'insertion|deletion', line, re.IGNORECASE):
|
|
264
|
+
colored_line = Colors.muted(line)
|
|
265
|
+
|
|
266
|
+
colored_lines.append(colored_line)
|
|
267
|
+
|
|
268
|
+
return '\n'.join(colored_lines)
|
|
269
|
+
|
|
270
|
+
def _colorize_remote(self, output: str) -> str:
|
|
271
|
+
"""Colorize remote operation outputs (push, pull, fetch)"""
|
|
272
|
+
lines = output.split('\n')
|
|
273
|
+
colored_lines = []
|
|
274
|
+
|
|
275
|
+
for line in lines:
|
|
276
|
+
colored_line = line
|
|
277
|
+
|
|
278
|
+
if re.search(r'From |To |->', line):
|
|
279
|
+
colored_line = color_info(line)
|
|
280
|
+
elif re.search(r'Fast-forward|Already up-to-date', line):
|
|
281
|
+
colored_line = color_success(line)
|
|
282
|
+
elif re.search(r'Fetching |Enumerating objects', line):
|
|
283
|
+
colored_line = Colors.muted(line)
|
|
284
|
+
elif re.search(r'Receiving objects|Resolving deltas', line):
|
|
285
|
+
colored_line = color_info(line)
|
|
286
|
+
elif re.search(r'error:|fatal:', line):
|
|
287
|
+
colored_line = color_error(line)
|
|
288
|
+
elif re.search(r'warning:', line):
|
|
289
|
+
colored_line = color_warning(line)
|
|
290
|
+
|
|
291
|
+
# Color branch names
|
|
292
|
+
branch_matches = re.findall(r'[\w\-/]+->[\w\-/]+', line)
|
|
293
|
+
for match in branch_matches:
|
|
294
|
+
parts = match.split('->')
|
|
295
|
+
if len(parts) == 2:
|
|
296
|
+
colored_line = colored_line.replace(
|
|
297
|
+
match,
|
|
298
|
+
f"{color_branch(parts[0])} -> {color_branch(parts[1])}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
colored_lines.append(colored_line)
|
|
302
|
+
|
|
303
|
+
return '\n'.join(colored_lines)
|
|
304
|
+
|
|
305
|
+
def _colorize_generic(self, output: str) -> str:
|
|
306
|
+
"""Generic coloring for other git commands"""
|
|
307
|
+
lines = output.split('\n')
|
|
308
|
+
colored_lines = []
|
|
309
|
+
|
|
310
|
+
for line in lines:
|
|
311
|
+
colored_line = line
|
|
312
|
+
|
|
313
|
+
# Error patterns
|
|
314
|
+
if re.search(r'^error:|^fatal:', line):
|
|
315
|
+
colored_line = color_error(line)
|
|
316
|
+
# Warning patterns
|
|
317
|
+
elif re.search(r'^warning:|^hint:', line):
|
|
318
|
+
colored_line = color_warning(line)
|
|
319
|
+
# Success patterns
|
|
320
|
+
elif re.search(r'^Successfully|^Already up-to-date', line):
|
|
321
|
+
colored_line = color_success(line)
|
|
322
|
+
# Info patterns
|
|
323
|
+
elif re.search(r'^Note:|^INFO:', line):
|
|
324
|
+
colored_line = color_info(line)
|
|
325
|
+
# File paths
|
|
326
|
+
else:
|
|
327
|
+
file_matches = re.findall(r'[/\\][\w\-./\\]+\.?\w*', line)
|
|
328
|
+
for match in file_matches:
|
|
329
|
+
colored_line = colored_line.replace(match, color_file(match))
|
|
330
|
+
|
|
331
|
+
colored_lines.append(colored_line)
|
|
332
|
+
|
|
333
|
+
return '\n'.join(colored_lines)
|
|
334
|
+
|
|
335
|
+
def _colorize_stderr(self, stderr: str) -> str:
|
|
336
|
+
"""Colorize stderr output"""
|
|
337
|
+
lines = stderr.split('\n')
|
|
338
|
+
colored_lines = []
|
|
339
|
+
|
|
340
|
+
for line in lines:
|
|
341
|
+
colored_line = line
|
|
342
|
+
|
|
343
|
+
if re.search(r'^error:|^fatal:', line):
|
|
344
|
+
colored_line = color_error(line)
|
|
345
|
+
elif re.search(r'^warning:|^hint:', line):
|
|
346
|
+
colored_line = color_warning(line)
|
|
347
|
+
else:
|
|
348
|
+
colored_line = Colors.muted(line)
|
|
349
|
+
|
|
350
|
+
colored_lines.append(colored_line)
|
|
351
|
+
|
|
352
|
+
return '\n'.join(colored_lines)
|
|
353
|
+
|
|
354
|
+
# Convenience function
|
|
355
|
+
def colorize_output(command: str, stdout: str, stderr: str) -> Tuple[str, str]:
|
|
356
|
+
"""Convenience function to colorize output"""
|
|
357
|
+
colorizer = OutputColorizer()
|
|
358
|
+
return colorizer.colorize_output(command, stdout, stderr)
|
src/repo_state.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# example for this dataclass object
|
|
2
|
+
# RepoState(
|
|
3
|
+
# branch="main",
|
|
4
|
+
# ahead_by=2,
|
|
5
|
+
# behind_by=1,
|
|
6
|
+
# staged_files=["fancygit.py"],
|
|
7
|
+
# unstaged_files=["git_runner.py"],
|
|
8
|
+
# untracked_files=["test.py"],
|
|
9
|
+
# has_conflicts=True
|
|
10
|
+
# is_merging = True,
|
|
11
|
+
# conflicts = [MergeConflict, .....]etc
|
|
12
|
+
# )
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from src.merge_conflict import MergeConflict
|
|
16
|
+
@dataclass (frozen = True)
|
|
17
|
+
class RepoState:
|
|
18
|
+
branch: str
|
|
19
|
+
ahead_by: int
|
|
20
|
+
behind_by: int
|
|
21
|
+
|
|
22
|
+
staged_files: list[str]
|
|
23
|
+
unstaged_files: list[str]
|
|
24
|
+
untracked_files: list[str]
|
|
25
|
+
|
|
26
|
+
has_conflicts: bool
|
|
27
|
+
is_merging: bool
|
|
28
|
+
|
|
29
|
+
conflicts: list[MergeConflict] # list of merge conflicts
|
src/utils.py
ADDED
|
File without changes
|
tests/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Testing Guide for FancyGit
|
|
2
|
+
|
|
3
|
+
This directory contains the test suite for the FancyGit project.
|
|
4
|
+
|
|
5
|
+
## Test Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
tests/
|
|
9
|
+
├── __init__.py # Package initialization
|
|
10
|
+
├── conftest.py # Pytest fixtures and configuration
|
|
11
|
+
├── test_git_error.py # Unit tests for GitError dataclass
|
|
12
|
+
├── test_git_error_parser.py # Unit tests for GitErrorParser
|
|
13
|
+
├── test_git_runner.py # Unit tests for GitRunner
|
|
14
|
+
├── test_fancygit_integration.py # Integration tests for FancyGit
|
|
15
|
+
├── test_conflict_parser_integration.py # Conflict parser integration tests
|
|
16
|
+
└── README.md # This file
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Test Categories
|
|
20
|
+
|
|
21
|
+
### Unit Tests (`@pytest.mark.unit`)
|
|
22
|
+
- Test individual components in isolation
|
|
23
|
+
- Fast and focused
|
|
24
|
+
- Use mocks to avoid external dependencies
|
|
25
|
+
|
|
26
|
+
### Integration Tests (`@pytest.mark.integration`)
|
|
27
|
+
- Test multiple components working together
|
|
28
|
+
- Slower but more comprehensive
|
|
29
|
+
- Test real workflows
|
|
30
|
+
|
|
31
|
+
### Slow Tests (`@pytest.mark.slow`)
|
|
32
|
+
- Tests that take significant time to run
|
|
33
|
+
- Often involve real git operations
|
|
34
|
+
- Not run by default in quick test runs
|
|
35
|
+
|
|
36
|
+
## Running Tests
|
|
37
|
+
|
|
38
|
+
### Quick Test Run (Recommended for Development)
|
|
39
|
+
```bash
|
|
40
|
+
# Run unit and integration tests (excludes slow tests)
|
|
41
|
+
make test
|
|
42
|
+
# or
|
|
43
|
+
pytest -m "unit or integration" -v
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Run Specific Test Categories
|
|
47
|
+
```bash
|
|
48
|
+
# Only unit tests
|
|
49
|
+
make test-unit
|
|
50
|
+
# or
|
|
51
|
+
pytest -m "unit" -v
|
|
52
|
+
|
|
53
|
+
# Only integration tests
|
|
54
|
+
make test-integration
|
|
55
|
+
# or
|
|
56
|
+
pytest -m "integration" -v
|
|
57
|
+
|
|
58
|
+
# Only slow tests
|
|
59
|
+
make test-slow
|
|
60
|
+
# or
|
|
61
|
+
pytest -m "slow" -v
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Run All Tests
|
|
65
|
+
```bash
|
|
66
|
+
make test-all
|
|
67
|
+
# or
|
|
68
|
+
pytest -v --cov=src --cov-report=html --cov-report=term-missing
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Individual Test Files
|
|
72
|
+
```bash
|
|
73
|
+
pytest tests/test_git_error.py -v
|
|
74
|
+
pytest tests/test_git_error_parser.py -v
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Coverage
|
|
78
|
+
|
|
79
|
+
Tests generate coverage reports to show how much of the codebase is tested:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Generate HTML coverage report
|
|
83
|
+
make test-unit
|
|
84
|
+
|
|
85
|
+
# View the report
|
|
86
|
+
open htmlcov/index.html # macOS
|
|
87
|
+
xdg-open htmlcov/index.html # Linux
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Fixtures
|
|
91
|
+
|
|
92
|
+
### `temp_git_repo`
|
|
93
|
+
Creates a temporary git repository for testing:
|
|
94
|
+
```python
|
|
95
|
+
def test_something(temp_git_repo):
|
|
96
|
+
# temp_git_repo is the path to a clean git repo
|
|
97
|
+
# Automatically cleaned up after the test
|
|
98
|
+
pass
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `sample_git_outputs`
|
|
102
|
+
Provides sample git command outputs:
|
|
103
|
+
```python
|
|
104
|
+
def test_parser(sample_git_outputs):
|
|
105
|
+
clean_output = sample_git_outputs["clean_output"]
|
|
106
|
+
error_output = sample_git_outputs["error_output"]
|
|
107
|
+
# ... test with sample data
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Writing New Tests
|
|
111
|
+
|
|
112
|
+
### Unit Test Example
|
|
113
|
+
```python
|
|
114
|
+
import pytest
|
|
115
|
+
from src.module import ClassToTest
|
|
116
|
+
|
|
117
|
+
@pytest.mark.unit
|
|
118
|
+
class TestClassToTest:
|
|
119
|
+
def test_method_should_return_expected_result(self):
|
|
120
|
+
# Arrange
|
|
121
|
+
obj = ClassToTest()
|
|
122
|
+
|
|
123
|
+
# Act
|
|
124
|
+
result = obj.method()
|
|
125
|
+
|
|
126
|
+
# Assert
|
|
127
|
+
assert result == expected_value
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Integration Test Example
|
|
131
|
+
```python
|
|
132
|
+
import pytest
|
|
133
|
+
from unittest.mock import patch
|
|
134
|
+
|
|
135
|
+
@pytest.mark.integration
|
|
136
|
+
class TestWorkflow:
|
|
137
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
138
|
+
def test_complete_workflow(self, mock_run_git):
|
|
139
|
+
# Setup mock
|
|
140
|
+
mock_run_git.return_value = (0, "success", "")
|
|
141
|
+
|
|
142
|
+
# Test workflow
|
|
143
|
+
# ...
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Best Practices
|
|
147
|
+
|
|
148
|
+
1. **Use descriptive test names** that explain what is being tested
|
|
149
|
+
2. **Follow Arrange-Act-Assert pattern** for clear test structure
|
|
150
|
+
3. **Mock external dependencies** to keep tests fast and reliable
|
|
151
|
+
4. **Use fixtures** for common setup code
|
|
152
|
+
5. **Mark tests appropriately** with `@pytest.mark.unit`, `@pytest.mark.integration`, or `@pytest.mark.slow`
|
|
153
|
+
6. **Test both happy path and error cases**
|
|
154
|
+
7. **Keep tests focused** - one assertion per test when possible
|
|
155
|
+
|
|
156
|
+
## CI/CD
|
|
157
|
+
|
|
158
|
+
Tests run automatically on:
|
|
159
|
+
- Push to `main` or `develop` branches
|
|
160
|
+
- Pull requests to `main` branch
|
|
161
|
+
|
|
162
|
+
The CI pipeline runs:
|
|
163
|
+
1. Unit tests with coverage on all Python versions (3.8-3.11) and OS platforms
|
|
164
|
+
2. Integration tests
|
|
165
|
+
3. Coverage reporting to Codecov
|
|
166
|
+
|
|
167
|
+
## Troubleshooting
|
|
168
|
+
|
|
169
|
+
### Tests Fail with Import Errors
|
|
170
|
+
Make sure you're running from the project root:
|
|
171
|
+
```bash
|
|
172
|
+
cd /path/to/Fancy_Git
|
|
173
|
+
pytest tests/
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Git Command Tests Fail
|
|
177
|
+
Ensure git is installed and available in your PATH:
|
|
178
|
+
```bash
|
|
179
|
+
git --version
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Slow Tests Taking Too Long
|
|
183
|
+
Skip slow tests during development:
|
|
184
|
+
```bash
|
|
185
|
+
pytest -m "not slow" -v
|
|
186
|
+
```
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import tempfile
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def temp_git_repo():
|
|
9
|
+
"""Create a temporary git repository for testing"""
|
|
10
|
+
temp_dir = tempfile.mkdtemp(prefix="fancygit_test_")
|
|
11
|
+
original_cwd = os.getcwd()
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
os.chdir(temp_dir)
|
|
15
|
+
|
|
16
|
+
# Initialize git repo with explicit commands for better Windows compatibility
|
|
17
|
+
import subprocess
|
|
18
|
+
subprocess.run(["git", "init"], check=True, capture_output=True)
|
|
19
|
+
subprocess.run(["git", "config", "user.name", "Test User"], check=True, capture_output=True)
|
|
20
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], check=True, capture_output=True)
|
|
21
|
+
|
|
22
|
+
# Create initial commit
|
|
23
|
+
with open("README.md", "w") as f:
|
|
24
|
+
f.write("# Test Repository\n")
|
|
25
|
+
subprocess.run(["git", "add", "README.md"], check=True, capture_output=True)
|
|
26
|
+
subprocess.run(["git", "commit", "-m", "Initial commit"], check=True, capture_output=True)
|
|
27
|
+
|
|
28
|
+
# Ensure working directory is clean
|
|
29
|
+
subprocess.run(["git", "status"], check=True, capture_output=True)
|
|
30
|
+
|
|
31
|
+
yield temp_dir
|
|
32
|
+
|
|
33
|
+
finally:
|
|
34
|
+
os.chdir(original_cwd)
|
|
35
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def sample_git_outputs():
|
|
39
|
+
"""Sample git command outputs for testing"""
|
|
40
|
+
return {
|
|
41
|
+
"clean_output": {
|
|
42
|
+
"stdout": "On branch main\nnothing to commit, working tree clean\n",
|
|
43
|
+
"stderr": ""
|
|
44
|
+
},
|
|
45
|
+
"error_output": {
|
|
46
|
+
"stdout": "",
|
|
47
|
+
"stderr": "error: pathspec 'nonexistent.txt' did not match any files\n"
|
|
48
|
+
},
|
|
49
|
+
"conflict_output": {
|
|
50
|
+
"stdout": "",
|
|
51
|
+
"stderr": "error: Merge conflict in README.md\n"
|
|
52
|
+
},
|
|
53
|
+
"warning_output": {
|
|
54
|
+
"stdout": "warning: LF will be replaced by CRLF in README.md\n",
|
|
55
|
+
"stderr": ""
|
|
56
|
+
},
|
|
57
|
+
"ahead_behind_output": {
|
|
58
|
+
"stdout": "Your branch is ahead of 'origin/main' by 1 commit\n",
|
|
59
|
+
"stderr": ""
|
|
60
|
+
}
|
|
61
|
+
}
|