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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import patch, MagicMock, call
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# Add project root to path for imports
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
8
|
+
|
|
9
|
+
from fancygit import FancyGit
|
|
10
|
+
|
|
11
|
+
@pytest.mark.integration
|
|
12
|
+
class TestFancyGitCommandWorkflows:
|
|
13
|
+
"""Integration tests for complete command workflows"""
|
|
14
|
+
|
|
15
|
+
def setup_method(self):
|
|
16
|
+
"""Setup method called before each test"""
|
|
17
|
+
with patch.object(FancyGit, '_load_commands', return_value=[
|
|
18
|
+
'add', 'commit', 'push', 'pull', 'status', 'branch', 'checkout',
|
|
19
|
+
'merge', 'rebase', 'reset', 'log', 'diff', 'stash', 'rm', 'mv', 'revert',
|
|
20
|
+
'welcome', 'confirmation', 'ai', 'colors', 'insights', 'visualize'
|
|
21
|
+
]):
|
|
22
|
+
with patch.object(FancyGit, '_load_confirmation_state', return_value=False):
|
|
23
|
+
with patch.object(FancyGit, '_load_ai_analysis_state', return_value=False):
|
|
24
|
+
with patch.object(FancyGit, '_load_output_coloring_state', return_value=False):
|
|
25
|
+
with patch.object(FancyGit, '_load_animation_type', return_value='dots'):
|
|
26
|
+
self.fancy_git = FancyGit()
|
|
27
|
+
|
|
28
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
29
|
+
def test_complete_add_commit_workflow(self, mock_run_git):
|
|
30
|
+
"""Test complete workflow: add -> commit -> push"""
|
|
31
|
+
# Mock the sequence of git commands
|
|
32
|
+
mock_run_git.side_effect = [
|
|
33
|
+
(0, "", ""), # git add .
|
|
34
|
+
(0, "[main 1234567] Initial commit\n 1 file changed, 1 insertion(+)", ""), # git commit
|
|
35
|
+
(0, "Enumerating objects: 3, done.\nTo github.com:user/repo.git\n 1234567..abcdefg main -> main", "") # git push
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Execute workflow
|
|
39
|
+
add_result = self.fancy_git.execute_command('add', '.')
|
|
40
|
+
commit_result = self.fancy_git.execute_command('commit', '-m', 'Initial commit')
|
|
41
|
+
push_result = self.fancy_git.execute_command('push', 'origin', 'main')
|
|
42
|
+
|
|
43
|
+
# Verify all commands succeeded
|
|
44
|
+
assert add_result is True
|
|
45
|
+
assert commit_result is True
|
|
46
|
+
assert push_result is True
|
|
47
|
+
|
|
48
|
+
# Verify the correct sequence of calls
|
|
49
|
+
expected_calls = [
|
|
50
|
+
call(['add', '.']),
|
|
51
|
+
call(['commit', '-m', 'Initial commit']),
|
|
52
|
+
call(['push', 'origin', 'main'])
|
|
53
|
+
]
|
|
54
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
55
|
+
|
|
56
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
57
|
+
def test_branch_creation_and_checkout_workflow(self, mock_run_git):
|
|
58
|
+
"""Test workflow: create branch -> checkout -> work -> merge back"""
|
|
59
|
+
mock_run_git.side_effect = [
|
|
60
|
+
(0, "", ""), # git branch feature-branch
|
|
61
|
+
(0, "Switched to branch 'feature-branch'", ""), # git checkout feature-branch
|
|
62
|
+
(0, "", ""), # git add .
|
|
63
|
+
(0, "[feature-branch abcdef123] Add feature\n 1 file changed, 1 insertion(+)", ""), # git commit
|
|
64
|
+
(0, "Switched to branch 'main'", ""), # git checkout main
|
|
65
|
+
(0, "Merge made by the 'recursive' strategy.\n 1 file changed, 1 insertion(+)", ""), # git merge feature-branch
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Execute workflow
|
|
69
|
+
branch_result = self.fancy_git.execute_command('branch', 'feature-branch')
|
|
70
|
+
checkout_result = self.fancy_git.execute_command('checkout', 'feature-branch')
|
|
71
|
+
add_result = self.fancy_git.execute_command('add', '.')
|
|
72
|
+
commit_result = self.fancy_git.execute_command('commit', '-m', 'Add feature')
|
|
73
|
+
checkout_main_result = self.fancy_git.execute_command('checkout', 'main')
|
|
74
|
+
merge_result = self.fancy_git.execute_command('merge', 'feature-branch')
|
|
75
|
+
|
|
76
|
+
# Verify all commands succeeded
|
|
77
|
+
assert all(result is True for result in [
|
|
78
|
+
branch_result, checkout_result, add_result,
|
|
79
|
+
commit_result, checkout_main_result, merge_result
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
# Verify the correct sequence of calls
|
|
83
|
+
expected_calls = [
|
|
84
|
+
call(['branch', 'feature-branch']),
|
|
85
|
+
call(['checkout', 'feature-branch']),
|
|
86
|
+
call(['add', '.']),
|
|
87
|
+
call(['commit', '-m', 'Add feature']),
|
|
88
|
+
call(['checkout', 'main']),
|
|
89
|
+
call(['merge', 'feature-branch'])
|
|
90
|
+
]
|
|
91
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
92
|
+
|
|
93
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
94
|
+
def test_stash_workflow(self, mock_run_git):
|
|
95
|
+
"""Test workflow: stash changes -> pull -> stash pop"""
|
|
96
|
+
mock_run_git.side_effect = [
|
|
97
|
+
(0, "Saved working directory and index state WIP on main: 1234567 Work in progress", ""), # git stash push
|
|
98
|
+
(0, "Already up to date.", ""), # git pull origin main
|
|
99
|
+
(0, "On branch main\nChanges not staged for commit:\n modified: test.py", ""), # git stash pop
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
# Execute workflow
|
|
103
|
+
stash_result = self.fancy_git.execute_command('stash', 'push', '-m', 'Work in progress')
|
|
104
|
+
pull_result = self.fancy_git.execute_command('pull', 'origin', 'main')
|
|
105
|
+
stash_pop_result = self.fancy_git.execute_command('stash', 'pop')
|
|
106
|
+
|
|
107
|
+
# Verify all commands succeeded
|
|
108
|
+
assert stash_result is True
|
|
109
|
+
assert pull_result is True
|
|
110
|
+
assert stash_pop_result is True
|
|
111
|
+
|
|
112
|
+
# Verify the correct sequence of calls
|
|
113
|
+
expected_calls = [
|
|
114
|
+
call(['stash', 'push', '-m', 'Work in progress']),
|
|
115
|
+
call(['pull', 'origin', 'main']),
|
|
116
|
+
call(['stash', 'pop'])
|
|
117
|
+
]
|
|
118
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
119
|
+
|
|
120
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
121
|
+
def test_reset_and_revert_workflow(self, mock_run_git):
|
|
122
|
+
"""Test workflow: reset hard -> revert commit"""
|
|
123
|
+
mock_run_git.side_effect = [
|
|
124
|
+
(0, "HEAD is now at 1234567 Previous commit", ""), # git reset --hard HEAD~1
|
|
125
|
+
(0, "[main abcdef123] Revert \"Previous commit\"\n 1 file changed, 1 deletion(-)", ""), # git revert HEAD
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
# Execute workflow
|
|
129
|
+
reset_result = self.fancy_git.execute_command('reset', '--hard', 'HEAD~1')
|
|
130
|
+
revert_result = self.fancy_git.execute_command('revert', 'HEAD')
|
|
131
|
+
|
|
132
|
+
# Verify all commands succeeded
|
|
133
|
+
assert reset_result is True
|
|
134
|
+
assert revert_result is True
|
|
135
|
+
|
|
136
|
+
# Verify the correct sequence of calls
|
|
137
|
+
expected_calls = [
|
|
138
|
+
call(['reset', '--hard', 'HEAD~1']),
|
|
139
|
+
call(['revert', 'HEAD'])
|
|
140
|
+
]
|
|
141
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
142
|
+
|
|
143
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
144
|
+
def test_conflict_resolution_workflow(self, mock_run_git):
|
|
145
|
+
"""Test workflow: handle merge conflicts"""
|
|
146
|
+
mock_run_git.side_effect = [
|
|
147
|
+
(1, "", "error: Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result."), # git merge feature-branch
|
|
148
|
+
(0, "Unstaged changes after reset:\nM\tREADME.md", ""), # git reset --merge
|
|
149
|
+
(0, "Merge made by the 'recursive' strategy.\n 1 file changed, 1 insertion(+)", ""), # git merge feature-branch (after fixing conflicts)
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Execute workflow
|
|
153
|
+
merge_conflict_result = self.fancy_git.execute_command('merge', 'feature-branch')
|
|
154
|
+
reset_result = self.fancy_git.execute_command('reset', '--merge')
|
|
155
|
+
merge_success_result = self.fancy_git.execute_command('merge', 'feature-branch')
|
|
156
|
+
|
|
157
|
+
# Verify conflict detection and resolution
|
|
158
|
+
assert merge_conflict_result is False # First merge should fail
|
|
159
|
+
assert reset_result is True
|
|
160
|
+
assert merge_success_result is True # Second merge should succeed
|
|
161
|
+
|
|
162
|
+
# Verify the correct sequence of calls
|
|
163
|
+
expected_calls = [
|
|
164
|
+
call(['merge', 'feature-branch']),
|
|
165
|
+
call(['reset', '--merge']),
|
|
166
|
+
call(['merge', 'feature-branch'])
|
|
167
|
+
]
|
|
168
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
169
|
+
|
|
170
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
171
|
+
def test_file_operations_workflow(self, mock_run_git):
|
|
172
|
+
"""Test workflow: file rename, modify, remove"""
|
|
173
|
+
mock_run_git.side_effect = [
|
|
174
|
+
(0, "Renaming 'old.txt' to 'new.txt'", ""), # git mv old.txt new.txt
|
|
175
|
+
(0, "", ""), # git add new.txt
|
|
176
|
+
(0, "[main 1234567] Rename file\n 1 file changed, 0 insertions(+), 0 deletions(-)\n rename old.txt => new.txt (100%)", ""), # git commit
|
|
177
|
+
(0, "rm 'new.txt'", ""), # git rm new.txt
|
|
178
|
+
(0, "[main abcdef123] Remove file\n 1 file changed, 1 deletion(-)", ""), # git commit
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# Execute workflow
|
|
182
|
+
mv_result = self.fancy_git.execute_command('mv', 'old.txt', 'new.txt')
|
|
183
|
+
add_result = self.fancy_git.execute_command('add', 'new.txt')
|
|
184
|
+
commit_mv_result = self.fancy_git.execute_command('commit', '-m', 'Rename file')
|
|
185
|
+
rm_result = self.fancy_git.execute_command('rm', 'new.txt')
|
|
186
|
+
commit_rm_result = self.fancy_git.execute_command('commit', '-m', 'Remove file')
|
|
187
|
+
|
|
188
|
+
# Verify all commands succeeded
|
|
189
|
+
assert all(result is True for result in [
|
|
190
|
+
mv_result, add_result, commit_mv_result, rm_result, commit_rm_result
|
|
191
|
+
])
|
|
192
|
+
|
|
193
|
+
# Verify the correct sequence of calls
|
|
194
|
+
expected_calls = [
|
|
195
|
+
call(['mv', 'old.txt', 'new.txt']),
|
|
196
|
+
call(['add', 'new.txt']),
|
|
197
|
+
call(['commit', '-m', 'Rename file']),
|
|
198
|
+
call(['rm', 'new.txt']),
|
|
199
|
+
call(['commit', '-m', 'Remove file'])
|
|
200
|
+
]
|
|
201
|
+
mock_run_git.assert_has_calls(expected_calls)
|
|
202
|
+
|
|
203
|
+
@pytest.mark.integration
|
|
204
|
+
class TestFancyGitSpecialCommandWorkflows:
|
|
205
|
+
"""Integration tests for special command workflows"""
|
|
206
|
+
|
|
207
|
+
def setup_method(self):
|
|
208
|
+
"""Setup method called before each test"""
|
|
209
|
+
with patch.object(FancyGit, '_load_commands', return_value=[
|
|
210
|
+
'add', 'commit', 'push', 'pull', 'status', 'branch', 'checkout',
|
|
211
|
+
'merge', 'rebase', 'reset', 'log', 'diff', 'stash', 'rm', 'mv',
|
|
212
|
+
'welcome', 'confirmation', 'ai', 'colors', 'insights', 'visualize'
|
|
213
|
+
]):
|
|
214
|
+
with patch.object(FancyGit, '_load_confirmation_state', return_value=False):
|
|
215
|
+
with patch.object(FancyGit, '_load_ai_analysis_state', return_value=False):
|
|
216
|
+
with patch.object(FancyGit, '_load_output_coloring_state', return_value=False):
|
|
217
|
+
with patch.object(FancyGit, '_load_animation_type', return_value='dots'):
|
|
218
|
+
self.fancy_git = FancyGit()
|
|
219
|
+
|
|
220
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
221
|
+
@patch('src.ollama_client.OllamaClient.test_connection')
|
|
222
|
+
@patch('src.ollama_client.OllamaClient.analyze_error_messages')
|
|
223
|
+
def test_ai_error_analysis_workflow(self, mock_analyze, mock_test_connection, mock_run_git):
|
|
224
|
+
"""Test AI error analysis workflow"""
|
|
225
|
+
mock_test_connection.return_value = True
|
|
226
|
+
mock_analyze.return_value = "This error occurs when the file doesn't exist. Check the file path and try again."
|
|
227
|
+
mock_run_git.return_value = (1, "", "error: pathspec 'nonexistent.txt' did not match any files")
|
|
228
|
+
|
|
229
|
+
# Enable AI analysis
|
|
230
|
+
ai_enable_result = self.fancy_git.execute_command('ai', 'on')
|
|
231
|
+
assert ai_enable_result is True
|
|
232
|
+
|
|
233
|
+
# Execute command that produces error
|
|
234
|
+
with patch('src.loading_animation.LoadingContext'):
|
|
235
|
+
add_result = self.fancy_git.execute_command('add', 'nonexistent.txt')
|
|
236
|
+
|
|
237
|
+
# Verify AI analysis was triggered
|
|
238
|
+
assert add_result is False
|
|
239
|
+
mock_analyze.assert_called_once()
|
|
240
|
+
|
|
241
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
242
|
+
@patch('src.output_colorizer.OutputColorizer.colorize_output')
|
|
243
|
+
def test_output_coloring_workflow(self, mock_colorize, mock_run_git):
|
|
244
|
+
"""Test output coloring workflow"""
|
|
245
|
+
mock_run_git.return_value = (0, "On branch main\nnothing to commit, working tree clean", "")
|
|
246
|
+
mock_colorize.return_value = ("On branch main\nnothing to commit, working tree clean", "")
|
|
247
|
+
|
|
248
|
+
# Enable output coloring
|
|
249
|
+
colors_enable_result = self.fancy_git.execute_command('colors', 'on')
|
|
250
|
+
assert colors_enable_result is True
|
|
251
|
+
|
|
252
|
+
# Execute command
|
|
253
|
+
status_result = self.fancy_git.execute_command('status')
|
|
254
|
+
|
|
255
|
+
# Verify output coloring was applied
|
|
256
|
+
assert status_result is True
|
|
257
|
+
mock_colorize.assert_called_once()
|
|
258
|
+
|
|
259
|
+
@patch('src.git_insights.GitInsights.generate_insights_report')
|
|
260
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
261
|
+
def test_insights_workflow(self, mock_run_git, mock_generate):
|
|
262
|
+
"""Test insights generation workflow"""
|
|
263
|
+
mock_run_git.return_value = (0, "On branch main", "")
|
|
264
|
+
mock_generate.return_value = {
|
|
265
|
+
'generated_at': '2024-01-01T12:00:00Z',
|
|
266
|
+
'analysis_period_days': 30,
|
|
267
|
+
'summary': {'total_commits': 10, 'active_contributors': 2, 'active_branches': 3, 'total_branches': 5, 'total_files_changed': 15},
|
|
268
|
+
'commit_frequency': {'Alice': 6, 'Bob': 4},
|
|
269
|
+
'branch_analysis': {
|
|
270
|
+
'main': {'type': 'local', 'status': 'active', 'days_inactive': 0},
|
|
271
|
+
'feature-branch': {'type': 'local', 'status': 'active', 'days_inactive': 5},
|
|
272
|
+
'old-branch': {'type': 'local', 'status': 'stale', 'days_inactive': 30}
|
|
273
|
+
},
|
|
274
|
+
'file_hotspots': {'src/main.py': 8, 'README.md': 4, 'tests/test_main.py': 3},
|
|
275
|
+
'contributor_stats': {
|
|
276
|
+
'Alice': {'commits': 6, 'lines_added': 150, 'lines_removed': 50},
|
|
277
|
+
'Bob': {'commits': 4, 'lines_added': 80, 'lines_removed': 30}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Generate insights
|
|
282
|
+
insights_result = self.fancy_git.execute_command('insights', '--days=30')
|
|
283
|
+
|
|
284
|
+
# Verify insights generation
|
|
285
|
+
assert insights_result is True
|
|
286
|
+
mock_generate.assert_called_once_with(30)
|
|
287
|
+
|
|
288
|
+
@patch('src.mermaid_export.MermaidExporter.export_all')
|
|
289
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
290
|
+
@patch('webbrowser.open')
|
|
291
|
+
def test_visualization_workflow(self, mock_browser, mock_run_git, mock_export):
|
|
292
|
+
"""Test repository visualization workflow"""
|
|
293
|
+
mock_run_git.return_value = (0, "On branch main", "")
|
|
294
|
+
mock_export.return_value = {
|
|
295
|
+
'status_mmd': '.fancygit/status.mmd',
|
|
296
|
+
'graph_mmd': '.fancygit/graph.mmd',
|
|
297
|
+
'tree_mmd': '.fancygit/tree.mmd',
|
|
298
|
+
'deps_mmd': '.fancygit/deps.mmd',
|
|
299
|
+
'html': '.fancygit/repo_visualization.html'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Generate visualization
|
|
303
|
+
with patch.object(self.fancy_git, 'get_repo_state', return_value={'branch': 'main', 'clean': True}):
|
|
304
|
+
viz_result = self.fancy_git.execute_command('visualize', '--max-commits=20')
|
|
305
|
+
|
|
306
|
+
# Verify visualization generation
|
|
307
|
+
assert viz_result is True
|
|
308
|
+
mock_export.assert_called_once()
|
|
309
|
+
mock_browser.assert_called_once()
|
|
310
|
+
|
|
311
|
+
@patch('builtins.input')
|
|
312
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
313
|
+
def test_confirmation_workflow(self, mock_run_git, mock_input):
|
|
314
|
+
"""Test confirmation workflow"""
|
|
315
|
+
mock_run_git.return_value = (0, "On branch main", "")
|
|
316
|
+
mock_input.return_value = 'y' # User confirms the command
|
|
317
|
+
|
|
318
|
+
# Enable confirmation for this test
|
|
319
|
+
self.fancy_git.confirmation_enabled = True
|
|
320
|
+
|
|
321
|
+
# Execute command with confirmation
|
|
322
|
+
status_result = self.fancy_git.execute_command('status')
|
|
323
|
+
|
|
324
|
+
# Verify confirmation was requested and command executed
|
|
325
|
+
assert status_result is True
|
|
326
|
+
mock_input.assert_called_once()
|
|
327
|
+
mock_run_git.assert_called_once_with(['status'])
|
|
328
|
+
|
|
329
|
+
@patch('builtins.input')
|
|
330
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
331
|
+
def test_confirmation_cancel_workflow(self, mock_run_git, mock_input):
|
|
332
|
+
"""Test confirmation cancellation workflow"""
|
|
333
|
+
mock_input.return_value = 'n' # User cancels the command
|
|
334
|
+
|
|
335
|
+
# Enable confirmation for this test
|
|
336
|
+
self.fancy_git.confirmation_enabled = True
|
|
337
|
+
|
|
338
|
+
# Execute command but cancel confirmation
|
|
339
|
+
status_result = self.fancy_git.execute_command('status')
|
|
340
|
+
|
|
341
|
+
# Verify confirmation was requested and command was cancelled
|
|
342
|
+
assert status_result is False
|
|
343
|
+
mock_input.assert_called_once()
|
|
344
|
+
mock_run_git.assert_not_called()
|
|
345
|
+
|
|
346
|
+
@pytest.mark.integration
|
|
347
|
+
class TestFancyGitErrorRecoveryWorkflows:
|
|
348
|
+
"""Integration tests for error recovery and edge cases"""
|
|
349
|
+
|
|
350
|
+
def setup_method(self):
|
|
351
|
+
"""Setup method called before each test"""
|
|
352
|
+
with patch.object(FancyGit, '_load_commands', return_value=['add', 'commit', 'push', 'pull', 'status']):
|
|
353
|
+
with patch.object(FancyGit, '_load_confirmation_state', return_value=False):
|
|
354
|
+
with patch.object(FancyGit, '_load_ai_analysis_state', return_value=False):
|
|
355
|
+
with patch.object(FancyGit, '_load_output_coloring_state', return_value=False):
|
|
356
|
+
with patch.object(FancyGit, '_load_animation_type', return_value='dots'):
|
|
357
|
+
self.fancy_git = FancyGit()
|
|
358
|
+
|
|
359
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
360
|
+
def test_network_error_recovery(self, mock_run_git):
|
|
361
|
+
"""Test recovery from network errors"""
|
|
362
|
+
# First call fails with network error, second succeeds
|
|
363
|
+
mock_run_git.side_effect = [
|
|
364
|
+
(1, "", "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com"),
|
|
365
|
+
(0, "Enumerating objects: 3, done.\nTo github.com:user/repo.git\n 1234567..abcdefg main -> main", "")
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
# First push fails
|
|
369
|
+
push_fail_result = self.fancy_git.execute_command('push', 'origin', 'main')
|
|
370
|
+
assert push_fail_result is False
|
|
371
|
+
|
|
372
|
+
# Second push succeeds
|
|
373
|
+
push_success_result = self.fancy_git.execute_command('push', 'origin', 'main')
|
|
374
|
+
assert push_success_result is True
|
|
375
|
+
|
|
376
|
+
# Verify both calls were made
|
|
377
|
+
assert mock_run_git.call_count == 2
|
|
378
|
+
|
|
379
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
380
|
+
def test_permission_error_handling(self, mock_run_git):
|
|
381
|
+
"""Test handling of permission errors"""
|
|
382
|
+
mock_run_git.return_value = (1, "", "error: unable to create file '.git/index.lock': Permission denied")
|
|
383
|
+
|
|
384
|
+
# Try to execute command that requires permissions
|
|
385
|
+
status_result = self.fancy_git.execute_command('status')
|
|
386
|
+
|
|
387
|
+
# Verify error was handled
|
|
388
|
+
assert status_result is False
|
|
389
|
+
mock_run_git.assert_called_once_with(['status'])
|
|
390
|
+
|
|
391
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
392
|
+
def test_repository_not_initialized(self, mock_run_git):
|
|
393
|
+
"""Test handling of non-git repository"""
|
|
394
|
+
mock_run_git.return_value = (1, "", "fatal: not a git repository (or any of the parent directories): .git")
|
|
395
|
+
|
|
396
|
+
# Try to execute git command
|
|
397
|
+
status_result = self.fancy_git.execute_command('status')
|
|
398
|
+
|
|
399
|
+
# Verify error was handled
|
|
400
|
+
assert status_result is False
|
|
401
|
+
mock_run_git.assert_called_once_with(['status'])
|
|
402
|
+
|
|
403
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
404
|
+
def test_subprocess_exception_handling(self, mock_run_git):
|
|
405
|
+
"""Test handling of subprocess exceptions"""
|
|
406
|
+
mock_run_git.return_value = (-1, "", "git command not found")
|
|
407
|
+
|
|
408
|
+
# Try to execute command when git is not available
|
|
409
|
+
status_result = self.fancy_git.execute_command('status')
|
|
410
|
+
|
|
411
|
+
# Verify exception was handled
|
|
412
|
+
assert isinstance(status_result, bool) # Should return False on exception
|
|
413
|
+
mock_run_git.assert_called_once_with(['status'])
|
|
414
|
+
|
|
415
|
+
@patch('src.git_runner.GitRunner.run_git_command')
|
|
416
|
+
def test_empty_repository_workflow(self, mock_run_git):
|
|
417
|
+
"""Test workflow with empty/new repository"""
|
|
418
|
+
mock_run_git.side_effect = [
|
|
419
|
+
(0, "On branch main\nNo commits yet", ""), # git status
|
|
420
|
+
(0, "", ""), # git add .
|
|
421
|
+
(0, "[main (root-commit) 1234567] Initial commit\n 1 file changed, 1 insertion(+)", ""), # git commit
|
|
422
|
+
(0, "Enumerating objects: 3, done.\nTo github.com:user/repo.git\n * [new branch] main -> main", ""), # git push
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
# Execute workflow in empty repository
|
|
426
|
+
status_result = self.fancy_git.execute_command('status')
|
|
427
|
+
add_result = self.fancy_git.execute_command('add', '.')
|
|
428
|
+
commit_result = self.fancy_git.execute_command('commit', '-m', 'Initial commit')
|
|
429
|
+
push_result = self.fancy_git.execute_command('push', '-u', 'origin', 'main')
|
|
430
|
+
|
|
431
|
+
# Verify all commands succeeded
|
|
432
|
+
assert all(result is True for result in [status_result, add_result, commit_result, push_result])
|
|
433
|
+
|
|
434
|
+
# Verify the correct sequence of calls
|
|
435
|
+
expected_calls = [
|
|
436
|
+
call(['status']),
|
|
437
|
+
call(['add', '.']),
|
|
438
|
+
call(['commit', '-m', 'Initial commit']),
|
|
439
|
+
call(['push', '-u', 'origin', 'main'])
|
|
440
|
+
]
|
|
441
|
+
mock_run_git.assert_has_calls(expected_calls)
|
tests/test_git_error.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from src.git_error import GitError
|
|
3
|
+
|
|
4
|
+
@pytest.mark.unit
|
|
5
|
+
class TestGitError:
|
|
6
|
+
"""Test cases for GitError dataclass"""
|
|
7
|
+
|
|
8
|
+
def test_git_error_creation_minimal(self):
|
|
9
|
+
"""Test creating GitError with minimal required fields"""
|
|
10
|
+
error = GitError(source="stdout", message="error: test message")
|
|
11
|
+
|
|
12
|
+
assert error.source == "stdout"
|
|
13
|
+
assert error.message == "error: test message"
|
|
14
|
+
assert error.severity == "unknown"
|
|
15
|
+
assert error.type is None
|
|
16
|
+
assert error.file is None
|
|
17
|
+
assert error.line is None
|
|
18
|
+
|
|
19
|
+
def test_git_error_creation_full(self):
|
|
20
|
+
"""Test creating GitError with all fields"""
|
|
21
|
+
error = GitError(
|
|
22
|
+
source="stderr",
|
|
23
|
+
message="fatal: merge conflict",
|
|
24
|
+
severity="error",
|
|
25
|
+
type="MERGE_CONFLICT",
|
|
26
|
+
file="README.md",
|
|
27
|
+
line=42
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert error.source == "stderr"
|
|
31
|
+
assert error.message == "fatal: merge conflict"
|
|
32
|
+
assert error.severity == "error"
|
|
33
|
+
assert error.type == "MERGE_CONFLICT"
|
|
34
|
+
assert error.file == "README.md"
|
|
35
|
+
assert error.line == 42
|
|
36
|
+
|
|
37
|
+
def test_git_error_immutability(self):
|
|
38
|
+
"""Test that GitError is immutable"""
|
|
39
|
+
error = GitError(source="stdout", message="test")
|
|
40
|
+
|
|
41
|
+
with pytest.raises(AttributeError):
|
|
42
|
+
error.source = "stderr"
|
|
43
|
+
|
|
44
|
+
with pytest.raises(AttributeError):
|
|
45
|
+
error.message = "changed"
|
|
46
|
+
|
|
47
|
+
def test_git_error_str_representation(self):
|
|
48
|
+
"""Test string representation of GitError"""
|
|
49
|
+
error = GitError(
|
|
50
|
+
source="stderr",
|
|
51
|
+
message="error: test message",
|
|
52
|
+
severity="error",
|
|
53
|
+
type="TEST_ERROR",
|
|
54
|
+
file="test.py",
|
|
55
|
+
line=10
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
str_repr = str(error)
|
|
59
|
+
assert "type: TEST_ERROR" in str_repr
|
|
60
|
+
assert "source: stderr" in str_repr
|
|
61
|
+
assert "message: error: test message" in str_repr
|
|
62
|
+
assert "severity: error" in str_repr
|
|
63
|
+
assert "file: test.py" in str_repr
|
|
64
|
+
assert "line: 10" in str_repr
|
|
65
|
+
assert "============================================================" in str_repr
|
|
66
|
+
|
|
67
|
+
def test_git_error_equality(self):
|
|
68
|
+
"""Test GitError equality comparison"""
|
|
69
|
+
error1 = GitError(source="stdout", message="test", severity="error")
|
|
70
|
+
error2 = GitError(source="stdout", message="test", severity="error")
|
|
71
|
+
error3 = GitError(source="stderr", message="test", severity="error")
|
|
72
|
+
|
|
73
|
+
assert error1 == error2
|
|
74
|
+
assert error1 != error3
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from src.git_error_parser import GitErrorParser
|
|
3
|
+
from src.git_error import GitError
|
|
4
|
+
|
|
5
|
+
@pytest.mark.unit
|
|
6
|
+
class TestGitErrorParser:
|
|
7
|
+
"""Test cases for GitErrorParser class"""
|
|
8
|
+
|
|
9
|
+
def setup_method(self):
|
|
10
|
+
"""Setup method called before each test"""
|
|
11
|
+
self.parser = GitErrorParser()
|
|
12
|
+
|
|
13
|
+
def test_detect_no_errors(self):
|
|
14
|
+
"""Test parsing clean git output"""
|
|
15
|
+
stdout = "On branch main\nnothing to commit, working tree clean\n"
|
|
16
|
+
stderr = ""
|
|
17
|
+
|
|
18
|
+
errors = self.parser.detect_warnings_errors(stdout, stderr)
|
|
19
|
+
|
|
20
|
+
assert len(errors) == 0
|
|
21
|
+
|
|
22
|
+
def test_detect_error_patterns(self):
|
|
23
|
+
"""Test detection of various error patterns"""
|
|
24
|
+
test_cases = [
|
|
25
|
+
("error: pathspec 'test.txt' did not match any files", "error", 1),
|
|
26
|
+
("fatal: not a git repository", "error", 1),
|
|
27
|
+
("failed to push some refs", "error", 1),
|
|
28
|
+
("rejected: non-fast-forward", "error", 1),
|
|
29
|
+
("conflict in README.md", "error", 1),
|
|
30
|
+
("merge conflict detected", "error", 2), # "conflict" and "merge conflict" patterns match
|
|
31
|
+
("unable to checkout", "error", 1)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for message, expected_severity, expected_count in test_cases:
|
|
35
|
+
errors = self.parser.detect_warnings_errors(message, "")
|
|
36
|
+
assert len(errors) == expected_count
|
|
37
|
+
assert all(error.severity == expected_severity for error in errors)
|
|
38
|
+
# Check that at least one error matches the full message
|
|
39
|
+
assert any(error.message == message for error in errors)
|
|
40
|
+
assert all(error.source == "stdout" for error in errors)
|
|
41
|
+
|
|
42
|
+
def test_detect_warning_patterns(self):
|
|
43
|
+
"""Test detection of various warning patterns"""
|
|
44
|
+
test_cases = [
|
|
45
|
+
("warning: LF will be replaced by CRLF", "warning", 2), # Both "warning:" and "WARNING:" patterns match
|
|
46
|
+
("WARNING: file is too large", "warning", 2), # Both patterns match
|
|
47
|
+
("Your branch is behind by 2 commits", "warning", 1), # Only "behind" pattern matches
|
|
48
|
+
("Your branch is ahead by 1 commit", "warning", 1), # Only "ahead" pattern matches
|
|
49
|
+
("branches have diverged", "warning", 1) # Only "diverged" pattern matches
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
for message, expected_severity, expected_count in test_cases:
|
|
53
|
+
errors = self.parser.detect_warnings_errors(message, "")
|
|
54
|
+
assert len(errors) == expected_count
|
|
55
|
+
assert all(error.severity == expected_severity for error in errors)
|
|
56
|
+
# Check that the message contains the expected pattern
|
|
57
|
+
assert all(error.message in message or message in error.message for error in errors)
|
|
58
|
+
assert all(error.source == "stdout" for error in errors)
|
|
59
|
+
|
|
60
|
+
def test_detect_stderr_errors(self):
|
|
61
|
+
"""Test error detection in stderr"""
|
|
62
|
+
stderr = "error: merge conflict in README.md\n"
|
|
63
|
+
|
|
64
|
+
errors = self.parser.detect_warnings_errors("", stderr)
|
|
65
|
+
|
|
66
|
+
assert len(errors) == 3 # "error:", "conflict", and "merge conflict" patterns match
|
|
67
|
+
assert all(error.severity == "error" for error in errors)
|
|
68
|
+
assert all(error.source == "stderr" for error in errors)
|
|
69
|
+
# Check that at least one error contains the full message
|
|
70
|
+
assert any("error: merge conflict in README.md" in error.message for error in errors)
|
|
71
|
+
|
|
72
|
+
def test_multiple_errors_same_output(self):
|
|
73
|
+
"""Test detection of multiple errors in same output"""
|
|
74
|
+
output = "error: first issue\nwarning: some warning\nfatal: critical error\n"
|
|
75
|
+
|
|
76
|
+
errors = self.parser.detect_warnings_errors(output, "")
|
|
77
|
+
|
|
78
|
+
assert len(errors) == 4 # warning matches both "warning:" and "WARNING:" patterns
|
|
79
|
+
error_severities = [error.severity for error in errors]
|
|
80
|
+
assert error_severities.count("error") == 2
|
|
81
|
+
assert error_severities.count("warning") == 2
|
|
82
|
+
|
|
83
|
+
def test_case_insensitive_matching(self):
|
|
84
|
+
"""Test that pattern matching is case insensitive"""
|
|
85
|
+
test_cases = [
|
|
86
|
+
("ERROR: uppercase error", 1),
|
|
87
|
+
("Error: Title case error", 1),
|
|
88
|
+
("WARNING: uppercase warning", 2), # Both "warning:" and "WARNING:" patterns match
|
|
89
|
+
("Warning: Title case warning", 2) # Both patterns match
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
for message, expected_count in test_cases:
|
|
93
|
+
errors = self.parser.detect_warnings_errors(message, "")
|
|
94
|
+
assert len(errors) == expected_count
|
|
95
|
+
assert all(error.message == message for error in errors)
|
|
96
|
+
|
|
97
|
+
def test_partial_word_matching(self):
|
|
98
|
+
"""Test that patterns match partial words correctly"""
|
|
99
|
+
output = "This command failed because it was unable to complete\n"
|
|
100
|
+
|
|
101
|
+
errors = self.parser.detect_warnings_errors(output, "")
|
|
102
|
+
|
|
103
|
+
assert len(errors) == 2
|
|
104
|
+
error_messages = [error.message for error in errors]
|
|
105
|
+
assert any("failed" in msg for msg in error_messages)
|
|
106
|
+
assert any("unable to" in msg for msg in error_messages)
|
|
107
|
+
|
|
108
|
+
def test_empty_outputs(self):
|
|
109
|
+
"""Test handling of empty outputs"""
|
|
110
|
+
errors = self.parser.detect_warnings_errors("", "")
|
|
111
|
+
assert len(errors) == 0
|
|
112
|
+
|
|
113
|
+
def test_complex_git_output(self, sample_git_outputs):
|
|
114
|
+
"""Test parsing realistic git command outputs"""
|
|
115
|
+
# Test clean output
|
|
116
|
+
clean_errors = self.parser.detect_warnings_errors(
|
|
117
|
+
sample_git_outputs["clean_output"]["stdout"],
|
|
118
|
+
sample_git_outputs["clean_output"]["stderr"]
|
|
119
|
+
)
|
|
120
|
+
assert len(clean_errors) == 0
|
|
121
|
+
|
|
122
|
+
# Test error output
|
|
123
|
+
error_errors = self.parser.detect_warnings_errors(
|
|
124
|
+
sample_git_outputs["error_output"]["stdout"],
|
|
125
|
+
sample_git_outputs["error_output"]["stderr"]
|
|
126
|
+
)
|
|
127
|
+
assert len(error_errors) == 1
|
|
128
|
+
assert error_errors[0].severity == "error"
|
|
129
|
+
assert "pathspec" in error_errors[0].message
|