Skip to content

Commit ca187b5

Browse files
authored
Merge pull request #45 from Matars/dev
Dev
2 parents f401de4 + d46f148 commit ca187b5

File tree

5 files changed

+211
-19
lines changed

5 files changed

+211
-19
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,31 @@ Fetch stats for specific user:
152152
gitfetch username
153153
```
154154

155+
### Repository-Specific Stats
156+
157+
Display contribution statistics for the current local git repository:
158+
159+
```bash
160+
gitfetch --local
161+
```
162+
163+
Shows commit activity over the last year, built from local git history
164+
165+
```bash
166+
gitfetch --graph-timeline
167+
```
168+
169+
Displays git commit timeline, build from local git history
170+
171+
**Current Limitations:**
172+
173+
- Only shows contribution graph and timeline
174+
- No repository metadata (stars, forks, issues, etc.)
175+
- No language statistics for the repository
176+
- Limited to local git history analysis
177+
178+
If you would like to expand this feature and and parse more repository data, please open an issue or submit a PR!
179+
155180
### Cache Options
156181

157182
Bypass cache and fetch fresh data:

src/gitfetch/cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ def parse_args() -> argparse.Namespace:
8585
help=argparse.SUPPRESS # Hide from help
8686
)
8787

88+
general_group.add_argument(
89+
"--local",
90+
action="store_true",
91+
help="Fetch data specific to current local repo (requires .git folder)"
92+
)
93+
8894
visual_group = parser.add_argument_group('\033[94mVisual Options\033[0m')
8995
visual_group.add_argument(
9096
"--spaced",
@@ -178,6 +184,12 @@ def main() -> int:
178184
try:
179185
args = parse_args()
180186

187+
# Check for --local flag
188+
if args.local:
189+
if not os.path.exists('.git'):
190+
print("Error: --local requires .git folder", file=sys.stderr)
191+
return 1
192+
181193
# Handle background refresh mode (hidden feature)
182194
if args.background_refresh:
183195
_background_refresh_cache_subprocess(args.background_refresh)
@@ -244,7 +256,7 @@ def main() -> int:
244256
not args.no_languages, not args.no_issues,
245257
not args.no_pr, not args.no_account,
246258
not args.no_grid, args.width, args.height,
247-
args.graph_timeline)
259+
args.graph_timeline, args.local)
248260
if args.spaced:
249261
spaced = True
250262
elif args.not_spaced:

src/gitfetch/display.py

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ def __init__(self, config_manager: ConfigManager,
2727
show_grid: bool = True,
2828
custom_width: Optional[int] = None,
2929
custom_height: Optional[int] = None,
30-
graph_timeline: bool = False):
30+
graph_timeline: bool = False,
31+
local_mode: bool = False):
3132
"""Initialize the display formatter."""
3233
terminal_size = shutil.get_terminal_size()
3334
self.terminal_width = terminal_size.columns
@@ -49,6 +50,7 @@ def __init__(self, config_manager: ConfigManager,
4950
self.custom_width = custom_width
5051
self.custom_height = custom_height
5152
self.graph_timeline = graph_timeline
53+
self.local_mode = local_mode
5254

5355
def display(self, username: str, user_data: Dict[str, Any],
5456
stats: Dict[str, Any], spaced=True) -> None:
@@ -316,14 +318,44 @@ def _display_compact(self, username: str, user_data: Dict[str, Any],
316318
info_part = (right_side[i] if i < len(right_side) else "")
317319
print(f"{graph_part}{padding} {info_part}")
318320

321+
def _reverse_truncate(self, line: str, max_width: int):
322+
pattern = r"(\x1b\[[0-9;]*m)(.*?)(?=(\x1b\[[0-9;]*m)|$)"
323+
matches = re.findall(pattern, line, flags=re.DOTALL)
324+
segments = [(m[0], m[1]) for m in matches]
325+
326+
width = sum(len(text) for _, text in segments)
327+
328+
if width <= max_width:
329+
return line.rjust(max_width)
330+
331+
result = []
332+
remaining = max_width
333+
334+
for color, text in reversed(segments):
335+
if remaining <= 0:
336+
break
337+
if len(text) <= remaining:
338+
result.append((color, text))
339+
remaining -= len(text)
340+
else:
341+
result.append((color, text[-remaining:]))
342+
remaining = 0
343+
344+
final = ''.join(color + text for color, text in reversed(result))
345+
final += self.colors.get('reset', '\x1b[0m')
346+
return final
347+
319348
def _display_full(self, username: str, user_data: Dict[str, Any],
320349
stats: Dict[str, Any], spaced=True) -> None:
321350
"""Display full layout with graph and all info sections."""
322-
if self.graph_timeline and not self.show_grid:
323-
# Show git timeline graph (only when no grid is shown)
351+
left_side = []
352+
ANSI_PATTERN = re.compile(r'\033\[[0-9;]*m')
353+
if self.graph_timeline:
354+
# Show git timeline graph instead of contribution graph
324355
try:
325-
timeline_text = self._get_graph_text()
326-
print(timeline_text)
356+
timeline_text = self._get_graph_text(False)
357+
left_side = timeline_text.split("\n")
358+
327359
# Still show right side info
328360
contrib_graph = stats.get('contribution_graph', [])
329361
info_lines = (self._format_user_info(user_data, stats)
@@ -342,10 +374,18 @@ def _display_full(self, username: str, user_data: Dict[str, Any],
342374
right_side.append("")
343375
right_side.extend(achievements)
344376

345-
if right_side:
377+
right_width = max(
378+
self._display_width(line) for line in right_side
379+
)
380+
381+
offset = 80
382+
max_width = self.terminal_width - right_width - offset
383+
384+
if right_side and left_side:
346385
print() # Add spacing
347-
for line in right_side:
348-
print(line)
386+
for l, r in zip(left_side, right_side):
387+
print(self._reverse_truncate(l, max_width),
388+
self.colors['reset'], r)
349389
except Exception as e:
350390
print(f"Error displaying timeline: {e}")
351391
return
@@ -354,7 +394,6 @@ def _display_full(self, username: str, user_data: Dict[str, Any],
354394
graph_width = (self.custom_width if self.custom_width is not None
355395
else max(50, (self.terminal_width - 10) * 3 // 4))
356396

357-
left_side = []
358397
if self.show_grid:
359398
left_side = self._get_contribution_graph_lines(
360399
contrib_graph,
@@ -453,6 +492,14 @@ def _get_contribution_graph_lines(self, weeks_data: list,
453492
Returns:
454493
List of strings representing graph lines
455494
"""
495+
if self.local_mode:
496+
try:
497+
weeks_data = self._get_local_contribution_weeks()
498+
except Exception as e:
499+
return [f"Error getting local contributions: {e}"]
500+
else:
501+
weeks_data = weeks_data
502+
456503
recent_weeks = self._get_recent_weeks(weeks_data)
457504
total_contribs = self._calculate_total_contributions(recent_weeks)
458505

@@ -509,22 +556,66 @@ def _get_contribution_graph_lines(self, weeks_data: list,
509556

510557
return lines
511558

512-
def _get_graph_text(vertical=False):
559+
def _get_local_contribution_weeks(self):
560+
"""Get contribution weeks from local git repository."""
561+
from .fetcher import BaseFetcher
562+
return BaseFetcher._build_contribution_graph_from_git()
563+
564+
def _get_graph_text(self, vertical=False):
513565
text = subprocess.check_output(
514-
['git', '--no-pager', 'log', '--graph', '--all', '--pretty=format:""']).decode().replace('"', '')
566+
['git', '--no-pager', 'log', '--color=always',
567+
'--graph', '--all', '--pretty=format:""']
568+
).decode().replace('"', '')
569+
515570
if vertical:
516571
return text
572+
517573
text = text.translate(str.maketrans(
518574
r"\/", r"\/"[::-1])).replace("|", "-")
575+
576+
ANSI_PATTERN = re.compile(r'\033\[[0-9;]*m')
577+
519578
lines = text.splitlines()
520-
max_len = max(len(line) for line in lines)
521-
padded = [line.ljust(max_len) for line in lines]
579+
parsed_lines = []
580+
for line in lines:
581+
parts = ANSI_PATTERN.split(line)
582+
codes = ANSI_PATTERN.findall(line)
583+
current_color = ''
584+
parsed = []
585+
for i, seg in enumerate(parts):
586+
for ch in seg:
587+
parsed.append((ch, current_color))
588+
if i < len(codes):
589+
code = codes[i]
590+
current_color = '' if code == '\033[0m' else code
591+
parsed_lines.append(parsed)
592+
593+
if not parsed_lines:
594+
return ''
595+
596+
max_len = max(len(line) for line in parsed_lines)
597+
padded = [line + [(' ', '')] * (max_len - len(line))
598+
for line in parsed_lines]
522599

523600
rotated = []
524601
for col in reversed(range(max_len)):
525-
new_line = ''.join(padded[row][col] for row in range(len(padded)))
526-
rotated.append(new_line)
527-
return '\n'.join(rotated)
602+
new_row = [padded[row][col] for row in range(len(padded))]
603+
rotated.append(new_row)
604+
605+
out_lines = []
606+
for row in rotated:
607+
cur_color = ''
608+
out_line = ''
609+
for ch, color in row:
610+
if color != cur_color:
611+
out_line += color
612+
cur_color = color
613+
out_line += ch
614+
if cur_color:
615+
out_line += '\033[0m'
616+
out_lines.append(out_line)
617+
618+
return '\n'.join(out_lines)
528619

529620
def _format_user_info_compact(self, user_data: Dict[str, Any],
530621
stats: Dict[str, Any]) -> list:

src/gitfetch/fetcher.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import subprocess
88
import json
99
import sys
10-
import os, re
10+
import os
11+
import re
12+
1113

1214
class BaseFetcher(ABC):
1315
"""Abstract base class for git hosting provider fetchers."""
@@ -58,6 +60,67 @@ def fetch_user_stats(self, username: str, user_data: Optional[Dict[str, Any]] =
5860
"""
5961
pass
6062

63+
@staticmethod
64+
def _build_contribution_graph_from_git(repo_path: str = ".") -> list:
65+
"""
66+
Build contribution graph from local .git history.
67+
68+
Args:
69+
repo_path: Path to the git repository (default: current dir)
70+
71+
Returns:
72+
List of weeks with contribution data
73+
"""
74+
from datetime import datetime, timedelta
75+
import collections
76+
77+
try:
78+
# Get commit dates
79+
result = subprocess.run(
80+
['git', 'log', '--pretty=format:%ai', '--all'],
81+
capture_output=True, text=True, cwd=repo_path
82+
)
83+
if result.returncode != 0:
84+
return []
85+
86+
commits = result.stdout.strip().split('\n')
87+
if not commits or commits == ['']:
88+
return []
89+
90+
# Parse dates and count commits per day
91+
commit_counts = collections.Counter()
92+
for commit in commits:
93+
if commit:
94+
date_str = commit.split(' ')[0] # YYYY-MM-DD
95+
commit_counts[date_str] += 1
96+
97+
# Get date range (last year)
98+
end_date = datetime.now().date()
99+
start_date = end_date - timedelta(days=365)
100+
101+
# Build weeks
102+
weeks = []
103+
current_date = start_date
104+
while current_date <= end_date:
105+
week = {'contributionDays': []}
106+
for i in range(7):
107+
day_date = current_date + timedelta(days=i)
108+
if day_date > end_date:
109+
break
110+
count = commit_counts.get(day_date.isoformat(), 0)
111+
week['contributionDays'].append({
112+
'contributionCount': count,
113+
'date': day_date.isoformat()
114+
})
115+
if week['contributionDays']:
116+
weeks.append(week)
117+
current_date += timedelta(days=7)
118+
119+
return weeks
120+
121+
except Exception:
122+
return []
123+
61124

62125
class GitHubFetcher(BaseFetcher):
63126
"""Fetches GitHub user data and statistics using GitHub CLI."""
@@ -114,7 +177,8 @@ def get_authenticated_user(self) -> str:
114177
)
115178
if result.returncode != 0:
116179
try:
117-
yml = open(os.path.expanduser("~/.config/gh/hosts.yml"),'r').read()
180+
yml = open(os.path.expanduser(
181+
"~/.config/gh/hosts.yml"), 'r').read()
118182
user = re.findall(" +user: +(.*)", yml)
119183
if len(user) != 0:
120184
return user[0]

tst.py

Whitespace-only changes.

0 commit comments

Comments
 (0)