Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,20 @@ Works with commands `--git-stats-by-branch` and `--csv-output-by-branch`.
export _GIT_BRANCH="master"
```

### Sorting Contribution Stats

You can sort contribution stats by field `name`, `commits`, `insertions`,
`deletions`, or `lines` (total lines changed), followed by a hyphen and
a direction (`asc`, `desc`).

```bash
export _GIT_SORT_BY="name-asc"
or
export _GIT_SORT_BY="lines-desc"
or
export _GIT_SORT_BY="deletions-asc"
```

### Commit Days

You can set the variable `_GIT_DAYS` to set the number of days for the heatmap.
Expand Down
54 changes: 54 additions & 0 deletions git_py_stats/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@
from git_py_stats.git_operations import run_git_command


def _parse_git_sort_by(raw: str) -> tuple[str, str]:
"""
Helper function for handling sorting features for contribution stats.
Handles the following metrics:
- "name" (default)
- "commits"
- "insertions"
- "deletions"
- "lines"
Handles the following directions:
- "asc" (default)
- "desc"

Args:
Raw string

Returns:
metric (str): The metric to sort by
direction (str): Whether we want ascending or descending
"""
allowed_metrics = {"name", "commits", "insertions", "deletions", "lines"}
metric = "name"
direction = "asc"

if not raw:
return metric, direction

parts = raw.strip().lower().split("-", 1)
if parts:
m = parts[0].strip()
if m in allowed_metrics:
metric = m
else:
print(f"WARNING: Invalid sort metric '{m}' set in _GIT_SORT_BY.", end=" ")
print("Falling back to 'name'.")
if len(parts) == 2:
d = parts[1].strip()
if d in {"asc", "desc"}:
direction = d
else:
print(f"WARNING: Invalid sort direction '{m}' in _GIT_SORT_BY.", end=" ")
print("Falling back to 'asc'.")

return metric, direction


# TODO: This is a rough equivalent of what the original program does.
# However, that doesn't mean this is the correct way to handle
# this type of operation since these are not much different
Expand Down Expand Up @@ -38,6 +84,8 @@ def get_config() -> Dict[str, Union[str, int]]:
_GIT_LIMIT (int): Limits the git log output. Defaults to 10.
_GIT_LOG_OPTIONS (str): Additional git log options. Default is empty.
_GIT_DAYS (int): Defines number of days for the heatmap. Default is empty.
_GIT_SORT_BY (str): Defines sort metric and direction for contribution stats.
Default is name-asc.
_MENU_THEME (str): Toggles between the default theme and legacy theme.
- 'legacy' to set the legacy theme
- 'none' to disable the menu theme
Expand Down Expand Up @@ -130,6 +178,12 @@ def get_config() -> Dict[str, Union[str, int]]:
else:
config["days"] = 30

# _GIT_SORT_BY
_git_sort_by_raw = os.environ.get("_GIT_SORT_BY", "")
sort_by, sort_dir = _parse_git_sort_by(_git_sort_by_raw)
config["sort_by"] = sort_by
config["sort_dir"] = sort_dir

# _MENU_THEME
menu_theme: Optional[str] = os.environ.get("_MENU_THEME")
if menu_theme == "legacy":
Expand Down
46 changes: 42 additions & 4 deletions git_py_stats/generate_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,42 @@
import collections
import csv
import json
from typing import Optional, Dict, Any, List, Union
from typing import Optional, Dict, Any, List, Union, Tuple
from datetime import datetime, timedelta

from git_py_stats.git_operations import run_git_command


# TODO: This can also be part of the future detailed_git_stats refactor
def _author_sort_key(item: Tuple[str, Dict[str, Any]], sort_by: str) -> Tuple:
"""
Helper function for detailed_git_stats to allow for easy sorting.

Args:
item: Tuple[str, Dict[str, Any]]: author_display_name and stats_dict
sort_by (str): 'name', 'commits', 'insertions', 'deletions', or 'lines'

Returns:
A key suitable for sorting.
"""
author, stats = item
commits = int(stats.get("commits", 0) or 0)
insertions = int(stats.get("insertions", 0) or 0)
deletions = int(stats.get("deletions", 0) or 0)
lines = int(stats.get("lines_changed", insertions + deletions) or 0)

if sort_by == "commits":
return (commits, author.lower())
if sort_by == "insertions":
return (insertions, author.lower())
if sort_by == "deletions":
return (deletions, author.lower())
if sort_by == "lines":
return (lines, author.lower())
# default: name
return (author.lower(),)


# TODO: We should really refactor this; It's huge
def detailed_git_stats(config: Dict[str, Union[str, int]], branch: Optional[str] = None) -> None:
"""
Expand Down Expand Up @@ -150,10 +180,18 @@ def detailed_git_stats(config: Dict[str, Union[str, int]], branch: Optional[str]
f"\n Contribution stats (by author) on the {'current' if not branch else branch} branch:\n"
)

# Sort authors alphabetically
sorted_authors = sorted(author_stats.items(), key=lambda x: x[0])
# Sort authors by env-configured metric/direction
sort_by = str(config.get("sort_by", "name")).lower()
sort_dir = str(config.get("sort_dir", "asc")).lower()
reverse = sort_dir == "desc"

author_items = list(author_stats.items())
author_items.sort(key=lambda it: _author_sort_key(it, sort_by), reverse=reverse)

if author_items:
print(f"\nSorting by: {sort_by} ({'desc' if reverse else 'asc'})\n")

for author, stats in sorted_authors:
for author, stats in author_items:
email = stats["email"]
insertions = stats["insertions"]
deletions = stats["deletions"]
Expand Down
107 changes: 107 additions & 0 deletions git_py_stats/tests/test_generate_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,113 @@ def setUp(self):
"menu_theme": "",
}

def _extract_printed_authors(self, mock_print):
"""
Return the list of author display lines in the order they were printed.
"""
authors = []
for call in mock_print.call_args_list:
msg = call.args[0] if call.args else ""
# lines look like " John Doe <[email protected]>:"
if isinstance(msg, str) and msg.strip().endswith(">:"):
authors.append(msg.strip()[:-1]) # drop trailing ":"
return authors

@patch("git_py_stats.generate_cmds.run_git_command")
@patch("builtins.print")
def test_sort_by_commits_desc(self, mock_print, mock_run_git_command):
"""
Test detailed_git_stats when sorting by commits in descending order.
"""
# Two authors, B has more commits but fewer insertions
mock_run_git_command.return_value = (
# A1 (2 commits total)
"c1\tAlice\[email protected]\t1609459200\n"
"10\t1\ta.py\n"
"c2\tAlice\[email protected]\t1609459300\n"
"5\t0\ta2.py\n"
# B1 (3 commits total)
"c3\tBob\[email protected]\t1609460000\n"
"1\t1\tb.py\n"
"c4\tBob\[email protected]\t1609460100\n"
"2\t2\tb2.py\n"
"c5\tBob\[email protected]\t1609460200\n"
"3\t3\tb3.py\n"
)

cfg = dict(self.mock_config)
cfg["sort_by"] = "commits"
cfg["sort_dir"] = "desc"

generate_cmds.detailed_git_stats(cfg)

authors = self._extract_printed_authors(mock_print)
# Expect Bob first (3 commits) then Alice (2)
self.assertGreaterEqual(len(authors), 2)
self.assertTrue(authors[0].startswith("Bob <[email protected]>"))
self.assertTrue(authors[1].startswith("Alice <[email protected]>"))
# Header shows sort choice
printed = " ".join(a.args[0] for a in mock_print.call_args_list if a.args)
self.assertIn("Sorting by: commits (desc)", printed)

@patch("git_py_stats.generate_cmds.run_git_command")
@patch("builtins.print")
def test_sort_by_lines_asc_with_name_tiebreaker(self, mock_print, mock_run_git_command):
"""
Test detailed_git_stats when sorting by lines in ascending order.
Attempts to handle a "tiebreaker" when sorting by falling back to
the person's name in ascending order. So if Alice and Bob have the
same number of commits, Alice should be chosen.
"""
mock_run_git_command.return_value = (
# Alice: 3+3 = 6 lines
"c1\tAlice\[email protected]\t1609459200\n"
"3\t3\ta.py\n"
# Bob: 4+2 = 6 lines
"c2\tBob\[email protected]\t1609460000\n"
"4\t2\tb.py\n"
)

cfg = dict(self.mock_config)
cfg["sort_by"] = "lines"
cfg["sort_dir"] = "asc"

generate_cmds.detailed_git_stats(cfg)

authors = self._extract_printed_authors(mock_print)
self.assertGreaterEqual(len(authors), 2)
self.assertTrue(authors[0].startswith("Alice <[email protected]>"))
self.assertTrue(authors[1].startswith("Bob <[email protected]>"))
printed = " ".join(a.args[0] for a in mock_print.call_args_list if a.args)
self.assertIn("Sorting by: lines (asc)", printed)

@patch("git_py_stats.generate_cmds.run_git_command")
@patch("builtins.print")
def test_sort_by_name_desc(self, mock_print, mock_run_git_command):
"""
Test detailed_git_stats when sorting by name in descending order.
"""
mock_run_git_command.return_value = (
"c1\tAlice\[email protected]\t1609459200\n"
"1\t0\ta.py\n"
"c2\tBob\[email protected]\t1609460000\n"
"1\t0\tb.py\n"
"c3\tCarol\[email protected]\t1609470000\n"
"1\t0\tc.py\n"
)

cfg = dict(self.mock_config)
cfg["sort_by"] = "name"
cfg["sort_dir"] = "desc"

generate_cmds.detailed_git_stats(cfg)

authors = self._extract_printed_authors(mock_print)
# Descending name: Carol, Bob, Alice
self.assertTrue(authors[0].startswith("Carol <[email protected]>"))
self.assertTrue(authors[1].startswith("Bob <[email protected]>"))
self.assertTrue(authors[2].startswith("Alice <[email protected]>"))

@patch("git_py_stats.generate_cmds.run_git_command")
@patch("builtins.print")
def test_detailed_git_stats(self, mock_print, mock_run_git_command):
Expand Down