From f18c03c883c1d157aa455179d9aa2ea21e1896d0 Mon Sep 17 00:00:00 2001 From: Tom Ice Date: Sun, 21 Sep 2025 13:16:17 -0400 Subject: [PATCH 1/2] Add ability to sort contribution stats * 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 --- README.md | 14 +++ git_py_stats/config.py | 49 +++++++++++ git_py_stats/generate_cmds.py | 46 +++++++++- git_py_stats/tests/test_generate_cmds.py | 107 +++++++++++++++++++++++ 4 files changed, 212 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aed7b93..0945afa 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/git_py_stats/config.py b/git_py_stats/config.py index bd60486..5ff07ad 100644 --- a/git_py_stats/config.py +++ b/git_py_stats/config.py @@ -8,6 +8,47 @@ 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" + Returns (sort_by, sort_dir) normalized. + Unknown metric -> 'name'; unknown dir -> 'asc'. + """ + 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 @@ -38,6 +79,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 sorting 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 @@ -130,6 +173,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": diff --git a/git_py_stats/generate_cmds.py b/git_py_stats/generate_cmds.py index 9f57052..579dedf 100644 --- a/git_py_stats/generate_cmds.py +++ b/git_py_stats/generate_cmds.py @@ -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: (author_display_name, stats_dict) + sort_by: one of 'name', 'commits', 'insertions', 'deletions', '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: """ @@ -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"] diff --git a/git_py_stats/tests/test_generate_cmds.py b/git_py_stats/tests/test_generate_cmds.py index f51ae2a..9ae3a6d 100644 --- a/git_py_stats/tests/test_generate_cmds.py +++ b/git_py_stats/tests/test_generate_cmds.py @@ -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 :" + 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\talice@example.com\t1609459200\n" + "10\t1\ta.py\n" + "c2\tAlice\talice@example.com\t1609459300\n" + "5\t0\ta2.py\n" + # B1 (3 commits total) + "c3\tBob\tbob@example.com\t1609460000\n" + "1\t1\tb.py\n" + "c4\tBob\tbob@example.com\t1609460100\n" + "2\t2\tb2.py\n" + "c5\tBob\tbob@example.com\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 ")) + self.assertTrue(authors[1].startswith("Alice ")) + # 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\talice@example.com\t1609459200\n" + "3\t3\ta.py\n" + # Bob: 4+2 = 6 lines + "c2\tBob\tbob@example.com\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 ")) + self.assertTrue(authors[1].startswith("Bob ")) + 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\talice@example.com\t1609459200\n" + "1\t0\ta.py\n" + "c2\tBob\tbob@example.com\t1609460000\n" + "1\t0\tb.py\n" + "c3\tCarol\tcarol@example.com\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 ")) + self.assertTrue(authors[1].startswith("Bob ")) + self.assertTrue(authors[2].startswith("Alice ")) + @patch("git_py_stats.generate_cmds.run_git_command") @patch("builtins.print") def test_detailed_git_stats(self, mock_print, mock_run_git_command): From 01caaf10ab227be7094fbc3d8c2564241bf054eb Mon Sep 17 00:00:00 2001 From: Tom Ice Date: Sun, 21 Sep 2025 13:32:03 -0400 Subject: [PATCH 2/2] Clarify comments in contribution stat sorting funcs --- git_py_stats/config.py | 11 ++++++++--- git_py_stats/generate_cmds.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/git_py_stats/config.py b/git_py_stats/config.py index 5ff07ad..8ec94af 100644 --- a/git_py_stats/config.py +++ b/git_py_stats/config.py @@ -20,8 +20,13 @@ def _parse_git_sort_by(raw: str) -> tuple[str, str]: Handles the following directions: - "asc" (default) - "desc" - Returns (sort_by, sort_dir) normalized. - Unknown metric -> 'name'; unknown dir -> 'asc'. + + 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" @@ -79,7 +84,7 @@ 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 sorting direction for contribution stats. + _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 diff --git a/git_py_stats/generate_cmds.py b/git_py_stats/generate_cmds.py index 579dedf..6813277 100644 --- a/git_py_stats/generate_cmds.py +++ b/git_py_stats/generate_cmds.py @@ -17,8 +17,8 @@ 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: (author_display_name, stats_dict) - sort_by: one of 'name', 'commits', 'insertions', 'deletions', 'lines' + 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.