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..8ec94af 100644 --- a/git_py_stats/config.py +++ b/git_py_stats/config.py @@ -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 @@ -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 @@ -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": diff --git a/git_py_stats/generate_cmds.py b/git_py_stats/generate_cmds.py index 9f57052..6813277 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: 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: """ @@ -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):