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.
src/git_insights.py ADDED
@@ -0,0 +1,304 @@
1
+ import subprocess
2
+ import json
3
+ import re
4
+ from datetime import datetime, timedelta
5
+ from collections import defaultdict, Counter
6
+ from typing import Dict, List, Tuple, Any
7
+
8
+ class GitInsights:
9
+ def __init__(self, git_runner):
10
+ self.runner = git_runner
11
+
12
+ def get_commit_frequency(self, days: int = 30) -> Dict[str, int]:
13
+ """Get commit frequency per contributor over specified days"""
14
+ since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
15
+ returncode, stdout, stderr = self.runner.run_git_command([
16
+ 'log', '--since', since_date, '--pretty=format:%an', '--date=short'
17
+ ])
18
+
19
+ if returncode != 0:
20
+ return {}
21
+
22
+ authors = stdout.strip().split('\n') if stdout.strip() else []
23
+ return dict(Counter(authors))
24
+
25
+ def get_lines_changed_over_time(self, days: int = 30) -> List[Dict]:
26
+ """Get lines added/removed over time"""
27
+ since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
28
+ returncode, stdout, stderr = self.runner.run_git_command([
29
+ 'log', '--since', since_date, '--pretty=format:%H|%ad|%an',
30
+ '--date=short', '--numstat'
31
+ ])
32
+
33
+ if returncode != 0:
34
+ return []
35
+
36
+ commits = []
37
+ current_commit = {}
38
+
39
+ for line in stdout.strip().split('\n'):
40
+ if '|' in line and len(line.split('|')) == 3:
41
+ if current_commit:
42
+ commits.append(current_commit)
43
+ hash_val, date, author = line.split('|')
44
+ current_commit = {
45
+ 'hash': hash_val,
46
+ 'date': date,
47
+ 'author': author,
48
+ 'files_changed': 0,
49
+ 'lines_added': 0,
50
+ 'lines_removed': 0
51
+ }
52
+ elif line.strip() and '\t' in line and current_commit:
53
+ # Parse numstat line: "10 5 file.py"
54
+ parts = line.strip().split('\t')
55
+ if len(parts) >= 3:
56
+ try:
57
+ added = int(parts[0]) if parts[0] != '-' else 0
58
+ removed = int(parts[1]) if parts[1] != '-' else 0
59
+ current_commit['lines_added'] += added
60
+ current_commit['lines_removed'] += removed
61
+ current_commit['files_changed'] += 1
62
+ except ValueError:
63
+ # Skip binary files
64
+ continue
65
+
66
+ if current_commit:
67
+ commits.append(current_commit)
68
+
69
+ return commits
70
+
71
+ def get_branch_analysis(self) -> Dict[str, Any]:
72
+ """Analyze branches - active vs stale, separating local and remote"""
73
+ branch_info = {}
74
+ now = datetime.now()
75
+
76
+ # First, fetch latest remote data to ensure we have up-to-date branch information
77
+ self.runner.run_git_command(['fetch', '--all'])
78
+
79
+ # Get local branches
80
+ returncode, stdout, stderr = self.runner.run_git_command(['branch'])
81
+ if returncode == 0:
82
+ for line in stdout.strip().split('\n'):
83
+ if line.strip():
84
+ branch = line.strip().replace('* ', '')
85
+ branch_name = f"local/{branch}"
86
+
87
+ # Get last commit date for branch
88
+ commit_returncode, commit_stdout, _ = self.runner.run_git_command([
89
+ 'log', '-1', '--pretty=format:%ad', '--date=iso', branch
90
+ ])
91
+
92
+ if commit_returncode == 0 and commit_stdout.strip():
93
+ try:
94
+ # Parse ISO date with timezone info
95
+ date_str = commit_stdout.strip()
96
+ if ' ' in date_str:
97
+ # Format like "2026-03-10 02:46:15 +0300"
98
+ last_commit_date = datetime.strptime(date_str.split()[0] + ' ' + date_str.split()[1], "%Y-%m-%d %H:%M:%S")
99
+ else:
100
+ # Format like "2026-03-10"
101
+ last_commit_date = datetime.strptime(date_str, "%Y-%m-%d")
102
+ except ValueError:
103
+ # Fallback to simple date parsing
104
+ last_commit_date = datetime.strptime(date_str.split()[0], "%Y-%m-%d")
105
+
106
+ days_inactive = (now - last_commit_date).days
107
+
108
+ branch_info[branch_name] = {
109
+ 'last_commit': commit_stdout.strip(),
110
+ 'days_inactive': days_inactive,
111
+ 'status': 'active' if days_inactive <= 30 else 'stale',
112
+ 'type': 'local'
113
+ }
114
+
115
+ # Get remote branches
116
+ returncode, stdout, stderr = self.runner.run_git_command(['branch', '-r'])
117
+ if returncode == 0:
118
+ for line in stdout.strip().split('\n'):
119
+ if line.strip():
120
+ branch = line.strip().replace('* ', '').replace('remotes/', '')
121
+ if not branch.startswith('HEAD ->'):
122
+ branch_name = f"remote/{branch}"
123
+
124
+ # Get last commit date for branch
125
+ commit_returncode, commit_stdout, _ = self.runner.run_git_command([
126
+ 'log', '-1', '--pretty=format:%ad', '--date=iso', branch
127
+ ])
128
+
129
+ if commit_returncode == 0 and commit_stdout.strip():
130
+ try:
131
+ # Parse ISO date with timezone info
132
+ date_str = commit_stdout.strip()
133
+ if ' ' in date_str:
134
+ # Format like "2026-03-10 02:46:15 +0300"
135
+ last_commit_date = datetime.strptime(date_str.split()[0] + ' ' + date_str.split()[1], "%Y-%m-%d %H:%M:%S")
136
+ else:
137
+ # Format like "2026-03-10"
138
+ last_commit_date = datetime.strptime(date_str, "%Y-%m-%d")
139
+ except ValueError:
140
+ # Fallback to simple date parsing
141
+ last_commit_date = datetime.strptime(date_str.split()[0], "%Y-%m-%d")
142
+
143
+ days_inactive = (now - last_commit_date).days
144
+
145
+ branch_info[branch_name] = {
146
+ 'last_commit': commit_stdout.strip(),
147
+ 'days_inactive': days_inactive,
148
+ 'status': 'active' if days_inactive <= 30 else 'stale',
149
+ 'type': 'remote'
150
+ }
151
+
152
+ return branch_info
153
+
154
+ def get_file_hotspots(self, days: int = 90) -> Dict[str, int]:
155
+ """Get files/directories with most changes"""
156
+ since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
157
+ returncode, stdout, stderr = self.runner.run_git_command([
158
+ 'log', '--since', since_date, '--name-only', '--pretty=format:'
159
+ ])
160
+
161
+ if returncode != 0:
162
+ return {}
163
+
164
+ files = []
165
+ for line in stdout.split('\n'):
166
+ if line.strip():
167
+ files.append(line.strip())
168
+
169
+ return dict(Counter(files))
170
+
171
+ def get_merge_efficiency(self, days: int = 90) -> Dict[str, Any]:
172
+ """Analyze merge/pull request efficiency"""
173
+ since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
174
+
175
+ # Get merge commits
176
+ returncode, stdout, stderr = self.runner.run_git_command([
177
+ 'log', '--since', since_date, '--merges', '--pretty=format:%H|%ad|%s',
178
+ '--date=iso'
179
+ ])
180
+
181
+ if returncode != 0:
182
+ return {}
183
+
184
+ merges = []
185
+ for line in stdout.strip().split('\n'):
186
+ if '|' in line:
187
+ hash_val, date, subject = line.split('|', 2)
188
+ merges.append({
189
+ 'hash': hash_val,
190
+ 'date': date,
191
+ 'subject': subject
192
+ })
193
+
194
+ # Calculate average time between merge commits
195
+ if len(merges) < 2:
196
+ return {'total_merges': len(merges), 'avg_merge_interval_days': 0}
197
+
198
+ merge_dates = []
199
+ for merge in merges:
200
+ try:
201
+ date_str = merge['date']
202
+ if ' ' in date_str:
203
+ # Format like "2026-03-10 02:46:15 +0300"
204
+ merge_date = datetime.strptime(date_str.split()[0] + ' ' + date_str.split()[1], "%Y-%m-%d %H:%M:%S")
205
+ else:
206
+ # Format like "2026-03-10"
207
+ merge_date = datetime.strptime(date_str, "%Y-%m-%d")
208
+ merge_dates.append(merge_date)
209
+ except ValueError:
210
+ # Skip invalid dates
211
+ continue
212
+
213
+ if len(merge_dates) < 2:
214
+ return {'total_merges': len(merges), 'avg_merge_interval_days': 0}
215
+
216
+ merge_dates.sort()
217
+
218
+ intervals = []
219
+ for i in range(1, len(merge_dates)):
220
+ interval = (merge_dates[i] - merge_dates[i-1]).days
221
+ intervals.append(interval)
222
+
223
+ avg_interval = sum(intervals) / len(intervals) if intervals else 0
224
+
225
+ return {
226
+ 'total_merges': len(merges),
227
+ 'avg_merge_interval_days': round(avg_interval, 1),
228
+ 'recent_merges': merges[-5:] # Last 5 merges
229
+ }
230
+
231
+ def get_contributor_stats(self) -> Dict[str, Dict]:
232
+ """Get comprehensive contributor statistics"""
233
+ returncode, stdout, stderr = self.runner.run_git_command([
234
+ 'log', '--pretty=format:%an|%ad', '--date=iso', '--numstat'
235
+ ])
236
+
237
+ if returncode != 0:
238
+ return {}
239
+
240
+ contributors = defaultdict(lambda: {
241
+ 'commits': 0,
242
+ 'lines_added': 0,
243
+ 'lines_removed': 0,
244
+ 'files_changed': 0,
245
+ 'first_commit': None,
246
+ 'last_commit': None
247
+ })
248
+
249
+ lines = stdout.strip().split('\n')
250
+ current_author = None
251
+ current_date = None
252
+
253
+ for line in lines:
254
+ if '|' in line and len(line.split('|')) == 2:
255
+ author, date = line.split('|')
256
+ current_author = author.strip()
257
+ current_date = date.strip()
258
+
259
+ if contributors[current_author]['first_commit'] is None:
260
+ contributors[current_author]['first_commit'] = current_date
261
+ contributors[current_author]['last_commit'] = current_date
262
+ contributors[current_author]['commits'] += 1
263
+
264
+ elif line.strip() and '\t' in line and current_author:
265
+ # Parse numstat line: "10 5 file.py"
266
+ parts = line.strip().split('\t')
267
+ if len(parts) >= 3:
268
+ try:
269
+ added = int(parts[0]) if parts[0] != '-' else 0
270
+ removed = int(parts[1]) if parts[1] != '-' else 0
271
+ contributors[current_author]['lines_added'] += added
272
+ contributors[current_author]['lines_removed'] += removed
273
+ contributors[current_author]['files_changed'] += 1
274
+ except ValueError:
275
+ # Skip binary files or invalid numbers
276
+ continue
277
+
278
+ return dict(contributors)
279
+
280
+ def generate_insights_report(self, days: int = 30) -> Dict[str, Any]:
281
+ """Generate comprehensive insights report"""
282
+ report = {
283
+ 'generated_at': datetime.now().isoformat(),
284
+ 'analysis_period_days': days,
285
+ 'commit_frequency': self.get_commit_frequency(days),
286
+ 'lines_changed': self.get_lines_changed_over_time(days),
287
+ 'branch_analysis': self.get_branch_analysis(),
288
+ 'file_hotspots': self.get_file_hotspots(days),
289
+ 'contributor_stats': self.get_contributor_stats()
290
+ }
291
+
292
+ # Add summary statistics
293
+ total_commits = sum(report['commit_frequency'].values())
294
+ active_branches = sum(1 for b in report['branch_analysis'].values() if b['status'] == 'active')
295
+
296
+ report['summary'] = {
297
+ 'total_commits': total_commits,
298
+ 'active_contributors': len(report['commit_frequency']),
299
+ 'active_branches': active_branches,
300
+ 'total_branches': len(report['branch_analysis']),
301
+ 'total_files_changed': len(report['file_hotspots'])
302
+ }
303
+
304
+ return report
src/git_runner.py ADDED
@@ -0,0 +1,20 @@
1
+ import subprocess
2
+
3
+ class GitRunner:
4
+ def __init__(self) -> None:
5
+ self.git_cmd = "git"
6
+
7
+ def run_git_command(self, args):
8
+ """Run git command and capture output"""
9
+ try:
10
+ cmd = [self.git_cmd] + args
11
+ result = subprocess.run(
12
+ cmd,
13
+ capture_output=True,
14
+ text=True,
15
+ check=False
16
+ )
17
+ return result.returncode, result.stdout, result.stderr
18
+ except Exception as e:
19
+ return -1, "", str(e)
20
+
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ import threading
3
+ import time
4
+ import sys
5
+ import itertools
6
+ import random
7
+
8
+
9
+ class LoadingAnimation:
10
+ """ASCII loading animations for long-running operations"""
11
+
12
+ def __init__(self):
13
+ self._stop_event = threading.Event()
14
+ self._thread = None
15
+ self._last_line_length = 0
16
+
17
+ def _clear_line(self):
18
+ """Clear the current line"""
19
+ sys.stdout.write('\r' + ' ' * self._last_line_length + '\r')
20
+ sys.stdout.flush()
21
+ self._last_line_length = 0
22
+
23
+ def _man_running(self):
24
+ """Man running animation"""
25
+ frames = [
26
+ "- - - - 🏃",
27
+ "- - - 🏃 -",
28
+ "- - 🏃 - -",
29
+ "- 🏃 - - -",
30
+ "🏃 - - - -"
31
+ ]
32
+ message = "🤖 AI is thinking"
33
+
34
+ while not self._stop_event.is_set():
35
+ for frame in frames:
36
+ if self._stop_event.is_set():
37
+ break
38
+ self._clear_line()
39
+ text = f"{message} {frame}"
40
+ sys.stdout.write(text)
41
+ sys.stdout.flush()
42
+ self._last_line_length = len(text)
43
+ time.sleep(0.3)
44
+
45
+ def _loading_dots(self):
46
+ """Simple loading dots animation"""
47
+ dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
48
+ message = "🤖 AI is analyzing"
49
+
50
+ while not self._stop_event.is_set():
51
+ for dot in dots:
52
+ if self._stop_event.is_set():
53
+ break
54
+ self._clear_line()
55
+ text = f"{message} {dot}"
56
+ sys.stdout.write(text)
57
+ sys.stdout.flush()
58
+ self._last_line_length = len(text)
59
+ time.sleep(0.1)
60
+
61
+ def _progress_bar(self):
62
+ """Progress bar animation"""
63
+ bar_chars = "░▒▓█"
64
+ message = "🤖 AI processing"
65
+
66
+ while not self._stop_event.is_set():
67
+ for i in range(21): # 0 to 20
68
+ if self._stop_event.is_set():
69
+ break
70
+ filled = i // 5
71
+ partial = i % 5
72
+ bar = "█" * filled + (bar_chars[partial] if partial < 3 else "█")
73
+ bar = bar.ljust(4, "░")
74
+ self._clear_line()
75
+ text = f"{message} [{bar}] {i*5}%"
76
+ sys.stdout.write(text)
77
+ sys.stdout.flush()
78
+ self._last_line_length = len(text)
79
+ time.sleep(0.15)
80
+
81
+ def _matrix_rain(self):
82
+ """Matrix-style falling characters"""
83
+ chars = "01"
84
+ message = "🤖 AI computing"
85
+
86
+ while not self._stop_event.is_set():
87
+ for _ in range(10):
88
+ if self._stop_event.is_set():
89
+ break
90
+ rain = ''.join(random.choice(chars) for _ in range(8))
91
+ self._clear_line()
92
+ text = f"{message} [{rain}]"
93
+ sys.stdout.write(text)
94
+ sys.stdout.flush()
95
+ self._last_line_length = len(text)
96
+ time.sleep(0.2)
97
+
98
+ def _brain_activity(self):
99
+ """Brain activity animation"""
100
+ frames = [
101
+ "🧠💭",
102
+ "🧠✨",
103
+ "🧠⚡",
104
+ "🧠💡",
105
+ "🧠🔮"
106
+ ]
107
+ message = "🤖 AI thinking"
108
+
109
+ while not self._stop_event.is_set():
110
+ for frame in frames:
111
+ if self._stop_event.is_set():
112
+ break
113
+ self._clear_line()
114
+ text = f"{message} {frame}"
115
+ sys.stdout.write(text)
116
+ sys.stdout.flush()
117
+ self._last_line_length = len(text)
118
+ time.sleep(0.4)
119
+
120
+ def start(self, animation_type="run"):
121
+ """Start the loading animation
122
+
123
+ Args:
124
+ animation_type: Type of animation ("run", "dots", "progress", "matrix", "brain")
125
+ """
126
+ if self._thread and self._thread.is_alive():
127
+ return
128
+
129
+ self._stop_event.clear()
130
+
131
+ animation_map = {
132
+ "run": self._man_running,
133
+ "dots": self._loading_dots,
134
+ "progress": self._progress_bar,
135
+ "matrix": self._matrix_rain,
136
+ "brain": self._brain_activity
137
+ }
138
+
139
+ animation_func = animation_map.get(animation_type, self._man_running)
140
+ self._thread = threading.Thread(target=animation_func, daemon=True)
141
+ self._thread.start()
142
+
143
+ def stop(self):
144
+ """Stop the loading animation and clear the line"""
145
+ if self._thread and self._thread.is_alive():
146
+ self._stop_event.set()
147
+ self._thread.join(timeout=0.5)
148
+
149
+ # Clear the entire line and move to beginning
150
+ sys.stdout.write('\r' + ' ' * 100 + '\r')
151
+ sys.stdout.flush()
152
+
153
+
154
+ # Context manager for easy usage
155
+ class LoadingContext:
156
+ """Context manager for loading animations"""
157
+
158
+ def __init__(self, animation_type="dots"):
159
+ self.animation = LoadingAnimation()
160
+ self.animation_type = animation_type
161
+
162
+ def __enter__(self):
163
+ self.animation.start(self.animation_type)
164
+ return self.animation
165
+
166
+ def __exit__(self, exc_type, exc_val, exc_tb):
167
+ self.animation.stop()
src/merge_conflict.py ADDED
@@ -0,0 +1,27 @@
1
+ # example for this dataclass object
2
+ # MergeConflict(
3
+ # file="fancygit.py",
4
+ # base_branch="main",
5
+ # incoming_branch="refactor/fancygit.py",
6
+ # current_code="return a + b",
7
+ # incoming_code="return a - b",
8
+ # start_line=12,
9
+ # end_line=34
10
+ # )
11
+
12
+ from dataclasses import dataclass
13
+
14
+ @dataclass (frozen=True)
15
+ class MergeConflict:
16
+ file: str
17
+
18
+ # to get hold of which branches
19
+ current_branch: str
20
+ incoming_branch: str
21
+
22
+ # to get hold of the line of code itself
23
+ current_code: str
24
+ incoming_code: str
25
+
26
+ start_line: int
27
+ end_line: int