Skip to content

Commit e83ab12

Browse files
authored
Merge pull request #12 from git-quick-stats/5-add-sorting-by-contribution-stats-field
* Adds a metric and a direction to sort contribution stats by exporting a shell environment before execution of the application. * Provides a default fallback in case there is any invalid data in the shell environment variable. * Handles a slight tiebreaker in case multiple people have similar metrics. * Adds some unit tests for the new functionality. * Update README.md to reflect the new feature. Closes #5
2 parents d525b88 + 01caaf1 commit e83ab12

File tree

4 files changed

+217
-4
lines changed

4 files changed

+217
-4
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,20 @@ Works with commands `--git-stats-by-branch` and `--csv-output-by-branch`.
311311
export _GIT_BRANCH="master"
312312
```
313313
314+
### Sorting Contribution Stats
315+
316+
You can sort contribution stats by field `name`, `commits`, `insertions`,
317+
`deletions`, or `lines` (total lines changed), followed by a hyphen and
318+
a direction (`asc`, `desc`).
319+
320+
```bash
321+
export _GIT_SORT_BY="name-asc"
322+
or
323+
export _GIT_SORT_BY="lines-desc"
324+
or
325+
export _GIT_SORT_BY="deletions-asc"
326+
```
327+
314328
### Commit Days
315329
316330
You can set the variable `_GIT_DAYS` to set the number of days for the heatmap.

git_py_stats/config.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,52 @@
88
from git_py_stats.git_operations import run_git_command
99

1010

11+
def _parse_git_sort_by(raw: str) -> tuple[str, str]:
12+
"""
13+
Helper function for handling sorting features for contribution stats.
14+
Handles the following metrics:
15+
- "name" (default)
16+
- "commits"
17+
- "insertions"
18+
- "deletions"
19+
- "lines"
20+
Handles the following directions:
21+
- "asc" (default)
22+
- "desc"
23+
24+
Args:
25+
Raw string
26+
27+
Returns:
28+
metric (str): The metric to sort by
29+
direction (str): Whether we want ascending or descending
30+
"""
31+
allowed_metrics = {"name", "commits", "insertions", "deletions", "lines"}
32+
metric = "name"
33+
direction = "asc"
34+
35+
if not raw:
36+
return metric, direction
37+
38+
parts = raw.strip().lower().split("-", 1)
39+
if parts:
40+
m = parts[0].strip()
41+
if m in allowed_metrics:
42+
metric = m
43+
else:
44+
print(f"WARNING: Invalid sort metric '{m}' set in _GIT_SORT_BY.", end=" ")
45+
print("Falling back to 'name'.")
46+
if len(parts) == 2:
47+
d = parts[1].strip()
48+
if d in {"asc", "desc"}:
49+
direction = d
50+
else:
51+
print(f"WARNING: Invalid sort direction '{m}' in _GIT_SORT_BY.", end=" ")
52+
print("Falling back to 'asc'.")
53+
54+
return metric, direction
55+
56+
1157
# TODO: This is a rough equivalent of what the original program does.
1258
# However, that doesn't mean this is the correct way to handle
1359
# this type of operation since these are not much different
@@ -38,6 +84,8 @@ def get_config() -> Dict[str, Union[str, int]]:
3884
_GIT_LIMIT (int): Limits the git log output. Defaults to 10.
3985
_GIT_LOG_OPTIONS (str): Additional git log options. Default is empty.
4086
_GIT_DAYS (int): Defines number of days for the heatmap. Default is empty.
87+
_GIT_SORT_BY (str): Defines sort metric and direction for contribution stats.
88+
Default is name-asc.
4189
_MENU_THEME (str): Toggles between the default theme and legacy theme.
4290
- 'legacy' to set the legacy theme
4391
- 'none' to disable the menu theme
@@ -130,6 +178,12 @@ def get_config() -> Dict[str, Union[str, int]]:
130178
else:
131179
config["days"] = 30
132180

181+
# _GIT_SORT_BY
182+
_git_sort_by_raw = os.environ.get("_GIT_SORT_BY", "")
183+
sort_by, sort_dir = _parse_git_sort_by(_git_sort_by_raw)
184+
config["sort_by"] = sort_by
185+
config["sort_dir"] = sort_dir
186+
133187
# _MENU_THEME
134188
menu_theme: Optional[str] = os.environ.get("_MENU_THEME")
135189
if menu_theme == "legacy":

git_py_stats/generate_cmds.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,42 @@
55
import collections
66
import csv
77
import json
8-
from typing import Optional, Dict, Any, List, Union
8+
from typing import Optional, Dict, Any, List, Union, Tuple
99
from datetime import datetime, timedelta
1010

1111
from git_py_stats.git_operations import run_git_command
1212

1313

14+
# TODO: This can also be part of the future detailed_git_stats refactor
15+
def _author_sort_key(item: Tuple[str, Dict[str, Any]], sort_by: str) -> Tuple:
16+
"""
17+
Helper function for detailed_git_stats to allow for easy sorting.
18+
19+
Args:
20+
item: Tuple[str, Dict[str, Any]]: author_display_name and stats_dict
21+
sort_by (str): 'name', 'commits', 'insertions', 'deletions', or 'lines'
22+
23+
Returns:
24+
A key suitable for sorting.
25+
"""
26+
author, stats = item
27+
commits = int(stats.get("commits", 0) or 0)
28+
insertions = int(stats.get("insertions", 0) or 0)
29+
deletions = int(stats.get("deletions", 0) or 0)
30+
lines = int(stats.get("lines_changed", insertions + deletions) or 0)
31+
32+
if sort_by == "commits":
33+
return (commits, author.lower())
34+
if sort_by == "insertions":
35+
return (insertions, author.lower())
36+
if sort_by == "deletions":
37+
return (deletions, author.lower())
38+
if sort_by == "lines":
39+
return (lines, author.lower())
40+
# default: name
41+
return (author.lower(),)
42+
43+
1444
# TODO: We should really refactor this; It's huge
1545
def detailed_git_stats(config: Dict[str, Union[str, int]], branch: Optional[str] = None) -> None:
1646
"""
@@ -150,10 +180,18 @@ def detailed_git_stats(config: Dict[str, Union[str, int]], branch: Optional[str]
150180
f"\n Contribution stats (by author) on the {'current' if not branch else branch} branch:\n"
151181
)
152182

153-
# Sort authors alphabetically
154-
sorted_authors = sorted(author_stats.items(), key=lambda x: x[0])
183+
# Sort authors by env-configured metric/direction
184+
sort_by = str(config.get("sort_by", "name")).lower()
185+
sort_dir = str(config.get("sort_dir", "asc")).lower()
186+
reverse = sort_dir == "desc"
187+
188+
author_items = list(author_stats.items())
189+
author_items.sort(key=lambda it: _author_sort_key(it, sort_by), reverse=reverse)
190+
191+
if author_items:
192+
print(f"\nSorting by: {sort_by} ({'desc' if reverse else 'asc'})\n")
155193

156-
for author, stats in sorted_authors:
194+
for author, stats in author_items:
157195
email = stats["email"]
158196
insertions = stats["insertions"]
159197
deletions = stats["deletions"]

git_py_stats/tests/test_generate_cmds.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,113 @@ def setUp(self):
2222
"menu_theme": "",
2323
}
2424

25+
def _extract_printed_authors(self, mock_print):
26+
"""
27+
Return the list of author display lines in the order they were printed.
28+
"""
29+
authors = []
30+
for call in mock_print.call_args_list:
31+
msg = call.args[0] if call.args else ""
32+
# lines look like " John Doe <[email protected]>:"
33+
if isinstance(msg, str) and msg.strip().endswith(">:"):
34+
authors.append(msg.strip()[:-1]) # drop trailing ":"
35+
return authors
36+
37+
@patch("git_py_stats.generate_cmds.run_git_command")
38+
@patch("builtins.print")
39+
def test_sort_by_commits_desc(self, mock_print, mock_run_git_command):
40+
"""
41+
Test detailed_git_stats when sorting by commits in descending order.
42+
"""
43+
# Two authors, B has more commits but fewer insertions
44+
mock_run_git_command.return_value = (
45+
# A1 (2 commits total)
46+
"c1\tAlice\t[email protected]\t1609459200\n"
47+
"10\t1\ta.py\n"
48+
"c2\tAlice\t[email protected]\t1609459300\n"
49+
"5\t0\ta2.py\n"
50+
# B1 (3 commits total)
51+
"c3\tBob\t[email protected]\t1609460000\n"
52+
"1\t1\tb.py\n"
53+
"c4\tBob\t[email protected]\t1609460100\n"
54+
"2\t2\tb2.py\n"
55+
"c5\tBob\t[email protected]\t1609460200\n"
56+
"3\t3\tb3.py\n"
57+
)
58+
59+
cfg = dict(self.mock_config)
60+
cfg["sort_by"] = "commits"
61+
cfg["sort_dir"] = "desc"
62+
63+
generate_cmds.detailed_git_stats(cfg)
64+
65+
authors = self._extract_printed_authors(mock_print)
66+
# Expect Bob first (3 commits) then Alice (2)
67+
self.assertGreaterEqual(len(authors), 2)
68+
self.assertTrue(authors[0].startswith("Bob <[email protected]>"))
69+
self.assertTrue(authors[1].startswith("Alice <[email protected]>"))
70+
# Header shows sort choice
71+
printed = " ".join(a.args[0] for a in mock_print.call_args_list if a.args)
72+
self.assertIn("Sorting by: commits (desc)", printed)
73+
74+
@patch("git_py_stats.generate_cmds.run_git_command")
75+
@patch("builtins.print")
76+
def test_sort_by_lines_asc_with_name_tiebreaker(self, mock_print, mock_run_git_command):
77+
"""
78+
Test detailed_git_stats when sorting by lines in ascending order.
79+
Attempts to handle a "tiebreaker" when sorting by falling back to
80+
the person's name in ascending order. So if Alice and Bob have the
81+
same number of commits, Alice should be chosen.
82+
"""
83+
mock_run_git_command.return_value = (
84+
# Alice: 3+3 = 6 lines
85+
"c1\tAlice\t[email protected]\t1609459200\n"
86+
"3\t3\ta.py\n"
87+
# Bob: 4+2 = 6 lines
88+
"c2\tBob\t[email protected]\t1609460000\n"
89+
"4\t2\tb.py\n"
90+
)
91+
92+
cfg = dict(self.mock_config)
93+
cfg["sort_by"] = "lines"
94+
cfg["sort_dir"] = "asc"
95+
96+
generate_cmds.detailed_git_stats(cfg)
97+
98+
authors = self._extract_printed_authors(mock_print)
99+
self.assertGreaterEqual(len(authors), 2)
100+
self.assertTrue(authors[0].startswith("Alice <[email protected]>"))
101+
self.assertTrue(authors[1].startswith("Bob <[email protected]>"))
102+
printed = " ".join(a.args[0] for a in mock_print.call_args_list if a.args)
103+
self.assertIn("Sorting by: lines (asc)", printed)
104+
105+
@patch("git_py_stats.generate_cmds.run_git_command")
106+
@patch("builtins.print")
107+
def test_sort_by_name_desc(self, mock_print, mock_run_git_command):
108+
"""
109+
Test detailed_git_stats when sorting by name in descending order.
110+
"""
111+
mock_run_git_command.return_value = (
112+
"c1\tAlice\t[email protected]\t1609459200\n"
113+
"1\t0\ta.py\n"
114+
"c2\tBob\t[email protected]\t1609460000\n"
115+
"1\t0\tb.py\n"
116+
"c3\tCarol\t[email protected]\t1609470000\n"
117+
"1\t0\tc.py\n"
118+
)
119+
120+
cfg = dict(self.mock_config)
121+
cfg["sort_by"] = "name"
122+
cfg["sort_dir"] = "desc"
123+
124+
generate_cmds.detailed_git_stats(cfg)
125+
126+
authors = self._extract_printed_authors(mock_print)
127+
# Descending name: Carol, Bob, Alice
128+
self.assertTrue(authors[0].startswith("Carol <[email protected]>"))
129+
self.assertTrue(authors[1].startswith("Bob <[email protected]>"))
130+
self.assertTrue(authors[2].startswith("Alice <[email protected]>"))
131+
25132
@patch("git_py_stats.generate_cmds.run_git_command")
26133
@patch("builtins.print")
27134
def test_detailed_git_stats(self, mock_print, mock_run_git_command):

0 commit comments

Comments
 (0)