Skip to content

Commit f95d1e7

Browse files
committed
Allow users to ignore authors
* Added new env var for ignoring authors and added filter for the CSV gen, branches by date, new contributors, and suggest reviewers * Various overall adjustments to handle new author filter * Remove TODO.md * Change name of the CSV output file to match the documentation * Prep for 0.2.0 release Closes #3
1 parent 18fdc98 commit f95d1e7

14 files changed

+169
-72
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,16 @@ Works with commands `--git-stats-by-branch` and `--csv-output-by-branch`.
313313
export _GIT_BRANCH="master"
314314
```
315315
316+
### Ignore Authors
317+
318+
You can set the variable `_GIT_IGNORE_AUTHORS` to filter out specific
319+
authors. It will currently work with the "Code reviewers", "New contributors",
320+
"All branches", and "Output daily stats by branch in CSV format" options.
321+
322+
```bash
323+
export _GIT_IGNORE_AUTHORS="(author@examle.com|username)"
324+
```
325+
316326
### Sorting Contribution Stats
317327
318328
You can sort contribution stats by field `name`, `commits`, `insertions`,

TODO.md

Lines changed: 0 additions & 26 deletions
This file was deleted.

git_py_stats/config.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,34 @@
33
"""
44

55
import os
6+
import re
67
from datetime import datetime
7-
from typing import Dict, Union, Optional
8+
from typing import Dict, Union, Optional, Callable
89
from git_py_stats.git_operations import run_git_command
910

1011

12+
def _build_author_exclusion_filter(pattern: str) -> Callable[[str], bool]:
13+
"""
14+
Compile a string of authors that tells you whether an author
15+
should be ignored based on a user-configured environment
16+
variable.
17+
18+
Args:
19+
pattern (str): A regex (Example: "(user@example.com|Some User)").
20+
No flags are injected automatically, but users can
21+
include them for case-insensitive matches.
22+
23+
Returns:
24+
Callable[[str], bool]: Input string 's' that matches the pattern to be
25+
ignored. False otherwise.
26+
"""
27+
pattern = (pattern or "").strip()
28+
if not pattern:
29+
return lambda _s: False
30+
rx = re.compile(pattern)
31+
return lambda s: bool(rx.search(s or ""))
32+
33+
1134
def _parse_git_sort_by(raw: str) -> tuple[str, str]:
1235
"""
1336
Helper function for handling sorting features for contribution stats.
@@ -86,6 +109,7 @@ def get_config() -> Dict[str, Union[str, int]]:
86109
_GIT_DAYS (int): Defines number of days for the heatmap. Default is empty.
87110
_GIT_SORT_BY (str): Defines sort metric and direction for contribution stats.
88111
Default is name-asc.
112+
_GIT_IGNORE_AUTHORS (str): Defines authors to ignore. Default is empty.
89113
_MENU_THEME (str): Toggles between the default theme and legacy theme.
90114
- 'legacy' to set the legacy theme
91115
- 'none' to disable the menu theme
@@ -102,6 +126,9 @@ def get_config() -> Dict[str, Union[str, int]]:
102126
- 'merges' (str): Git command option for merge commit view strategy.
103127
- 'limit' (int): Git log output limit.
104128
- 'log_options' (str): Additional git log options.
129+
- 'sort_by' (str): Sort by field and sort direction (asc/desc).
130+
- 'days' (str): Number of days for the heatmap.
131+
- 'ignore_authors': (str): Any author(s) to ignore.
105132
- 'menu_theme' (str): Menu theme color.
106133
"""
107134
config: Dict[str, Union[str, int]] = {}
@@ -184,6 +211,10 @@ def get_config() -> Dict[str, Union[str, int]]:
184211
config["sort_by"] = sort_by
185212
config["sort_dir"] = sort_dir
186213

214+
# _GIT_IGNORE_AUTHORS
215+
ignore_authors_pattern: Optional[str] = os.environ.get("_GIT_IGNORE_AUTHORS")
216+
config["ignore_authors"] = _build_author_exclusion_filter(ignore_authors_pattern)
217+
187218
# _MENU_THEME
188219
menu_theme: Optional[str] = os.environ.get("_MENU_THEME")
189220
if menu_theme == "legacy":

git_py_stats/generate_cmds.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ def output_daily_stats_csv(config: Dict[str, Union[str, int]]) -> None:
451451
until = config.get("until", "")
452452
log_options = config.get("log_options", "")
453453
pathspec = config.get("pathspec", "")
454+
ignore_authors = config.get("ignore_authors", lambda _s: False)
454455

455456
branch = input("Enter branch name (leave empty for current branch): ")
456457

@@ -478,22 +479,70 @@ def output_daily_stats_csv(config: Dict[str, Union[str, int]]) -> None:
478479
cmd = [arg for arg in cmd if arg]
479480

480481
output = run_git_command(cmd)
481-
if output:
482-
dates = output.split("\n")
483-
counter = collections.Counter(dates)
484-
filename = "daily_stats.csv"
485-
try:
486-
with open(filename, "w", newline="") as csvfile:
487-
fieldnames = ["Date", "Commits"]
488-
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
489-
writer.writeheader()
490-
for date, count in sorted(counter.items()):
491-
writer.writerow({"Date": date, "Commits": count})
492-
print(f"Daily stats saved to {filename}")
493-
except IOError as e:
494-
print(f"Failed to write to {filename}: {e}")
495-
else:
482+
483+
# Exit early if no output valid
484+
if not output:
496485
print("No data available.")
486+
return
487+
488+
# NOTE: This has to be expanded to handle the new ability to ignore
489+
# authors, but there might be a better way to handle this...
490+
kept_lines = []
491+
current_block = []
492+
current_ignored = False
493+
have_seen_author = False
494+
495+
for line in output.splitlines():
496+
# New commit starts
497+
if line.startswith("commit "):
498+
# Flush the previous block
499+
if current_block and not current_ignored:
500+
kept_lines.extend(current_block)
501+
# Reset for the next block
502+
current_block = [line]
503+
current_ignored = False
504+
have_seen_author = False
505+
continue
506+
507+
# Only check author once per block
508+
if not have_seen_author and line.startswith("Author: "):
509+
author_line = line[len("Author: ") :].strip()
510+
name = author_line
511+
email = ""
512+
if "<" in author_line and ">" in author_line:
513+
name = author_line.split("<", 1)[0].strip()
514+
email = author_line.split("<", 1)[1].split(">", 1)[0].strip()
515+
516+
# If any form matches (name or email), drop the whole block
517+
if (
518+
ignore_authors(author_line)
519+
or ignore_authors(name)
520+
or (email and ignore_authors(email))
521+
):
522+
current_ignored = True
523+
have_seen_author = True
524+
current_block.append(line)
525+
526+
# Flush the last block
527+
if current_block and not current_ignored:
528+
kept_lines.extend(current_block)
529+
530+
# Found nothing worth keeping? Just exit then
531+
if not kept_lines:
532+
print("No data available.")
533+
return
534+
535+
counter = collections.Counter(kept_lines)
536+
filename = "git_daily_stats.csv"
537+
try:
538+
with open(filename, "w", newline="") as csvfile:
539+
writer = csv.DictWriter(csvfile, fieldnames=["Date", "Commits"])
540+
writer.writeheader()
541+
for text, count in sorted(counter.items()):
542+
writer.writerow({"Date": text, "Commits": count})
543+
print(f"Daily stats saved to {filename}")
544+
except IOError as e:
545+
print(f"Failed to write to {filename}: {e}")
497546

498547

499548
# TODO: This doesn't match the original functionality as it uses some pretty

git_py_stats/interactive_mode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def handle_interactive_mode(config: Dict[str, Union[str, int]]) -> None:
3030
"6": lambda: generate_cmds.output_daily_stats_csv(config),
3131
"7": lambda: generate_cmds.save_git_log_output_json(config),
3232
"8": lambda: list_cmds.branch_tree(config),
33-
"9": list_cmds.branches_by_date,
33+
"9": lambda: list_cmds.branches_by_date(config),
3434
"10": lambda: list_cmds.contributors(config),
3535
"11": lambda: list_cmds.new_contributors(config, input("Enter cutoff date (YYYY-MM-DD): ")),
3636
"12": lambda: list_cmds.git_commits_per_author(config),

git_py_stats/list_cmds.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,24 +79,32 @@ def branch_tree(config: Dict[str, Union[str, int]]) -> None:
7979
print("No data available.")
8080

8181

82-
def branches_by_date() -> None:
82+
def branches_by_date(config: Dict[str, Union[str, int]]) -> None:
8383
"""
8484
Lists branches sorted by the latest commit date.
8585
8686
Args:
87-
None
87+
config: Dict[str, Union[str, int]]: Config dictionary holding env vars.
8888
8989
Returns:
9090
None
9191
"""
9292

93+
# Grab the config options from our config.py.
94+
ignore_authors = config.get("ignore_authors", lambda _s: False)
95+
9396
# Original command:
9497
# git for-each-ref --sort=committerdate refs/heads/ \
9598
# --format='[%(authordate:relative)] %(authorname) %(refname:short)' | cat -n
9699
# TODO: Wouldn't git log --pretty=format:'%ad' --date=short be better here?
97100
# Then we could pipe it through sort, uniq -c, sort -nr, etc.
98101
# Possibly feed back into the parent project
99-
format_str = "[%(authordate:relative)] %(authorname) %(refname:short)"
102+
103+
# Include the email so we can filter based off it, but keep the visible
104+
# part the same as before.
105+
visible_fmt = "[%(authordate:relative)] %(authorname) %(refname:short)"
106+
format_str = f"{visible_fmt}|%(authoremail)"
107+
100108
cmd = [
101109
"git",
102110
"for-each-ref",
@@ -106,20 +114,35 @@ def branches_by_date() -> None:
106114
]
107115

108116
output = run_git_command(cmd)
109-
if output:
110-
# Split the output into lines
111-
lines = output.split("\n")
117+
if not output:
118+
print("No commits found.")
119+
return
112120

113-
# Number the lines similar to 'cat -n'
114-
numbered_lines = [f"{idx + 1} {line}" for idx, line in enumerate(lines)]
121+
# Split lines and filter by author (both name and email), but keep
122+
# visible text only.
123+
visible_lines = []
124+
for raw in output.split("\n"):
125+
if not raw.strip():
126+
continue
127+
if "|" in raw:
128+
visible, email = raw.split("|", 1)
129+
else:
130+
visible, email = raw, ""
115131

116-
# Output numbered lines
117-
print("All branches (sorted by most recent commit):\n")
118-
for line in numbered_lines:
119-
print(f"\t{line}")
120-
else:
132+
# Filter by either email or the visible chunk.
133+
if ignore_authors(email) or ignore_authors(visible):
134+
continue
135+
136+
visible_lines.append(visible)
137+
138+
if not visible_lines:
121139
print("No commits found.")
140+
return
122141

142+
# Number like `cat -n`
143+
print("All branches (sorted by most recent commit):\n")
144+
for idx, line in enumerate(visible_lines, 1):
145+
print(f"\t{idx} {line}")
123146

124147
def contributors(config: Dict[str, Union[str, int]]) -> None:
125148
"""
@@ -213,6 +236,7 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
213236
until = config.get("until", "")
214237
log_options = config.get("log_options", "")
215238
pathspec = config.get("pathspec", "")
239+
ignore_authors = config.get("ignore_authors", lambda _s: False)
216240

217241
# Original command:
218242
# git -c log.showSignature=false log --use-mailmap $_merges \
@@ -245,6 +269,9 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
245269
try:
246270
email, timestamp = line.split("|")
247271
timestamp = int(timestamp)
272+
# Skip ignored by email
273+
if ignore_authors(email):
274+
continue
248275
# If the contributor is not in the dictionary or the current timestamp is earlier
249276
if email not in contributors_dict or timestamp < contributors_dict[email]:
250277
contributors_dict[email] = timestamp
@@ -283,12 +310,14 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
283310
name_cmd = [arg for arg in name_cmd if arg]
284311

285312
# Grab name + email if we can. Otherwise, just grab email
286-
name = run_git_command(name_cmd)
287-
if name:
288-
new_contributors_list.append((name, email))
289-
else:
290-
new_contributors_list.append(("", email))
291-
313+
# while also making sure to ignore any authors that may be
314+
# in our ignore_author env var
315+
name = (run_git_command(name_cmd) or "").strip()
316+
combo = f"{name} <{email}>" if name else f"<{email}>"
317+
if ignore_authors(email) or ignore_authors(name) or ignore_authors(combo):
318+
continue
319+
320+
new_contributors_list.append((name, email))
292321
# Sort the list alphabetically by name to match the original
293322
# and print all of this out
294323
if new_contributors_list:

git_py_stats/non_interactive_mode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def handle_non_interactive_mode(args: Namespace, config: Dict[str, Union[str, in
3030
"csv_output_by_branch": lambda: generate_cmds.output_daily_stats_csv(config),
3131
"json_output": lambda: generate_cmds.save_git_log_output_json(config),
3232
"branch_tree": lambda: list_cmds.branch_tree(config),
33-
"branches_by_date": list_cmds.branches_by_date,
33+
"branches_by_date": lambda: list_cmds.branches_by_date(config),
3434
"contributors": lambda: list_cmds.contributors(config),
3535
"new_contributors": lambda: list_cmds.new_contributors(config, args.new_contributors),
3636
"commits_per_author": lambda: list_cmds.git_commits_per_author(config),

git_py_stats/suggest_cmds.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def suggest_reviewers(config: Dict[str, Union[str, int]]) -> None:
3434
until = config.get("until", "")
3535
log_options = config.get("log_options", "")
3636
pathspec = config.get("pathspec", "")
37+
ignore_authors = config.get("ignore_authors", lambda _s: False)
3738

3839
cmd = [
3940
"git",
@@ -64,6 +65,9 @@ def suggest_reviewers(config: Dict[str, Union[str, int]]) -> None:
6465
lines = [line.strip() for line in output.splitlines()]
6566
lines = [line for line in lines if line]
6667

68+
# Drop ignored authors (name-or-email patterns both supported)
69+
lines = [a for a in lines if not ignore_authors(a)]
70+
6771
# Return early if nothing found
6872
if not lines:
6973
print("No potential reviewers found.")

git_py_stats/tests/test_generate_cmds.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,11 @@ def test_output_daily_stats_csv(self, mock_print, mock_input, mock_run_git_comma
268268
generate_cmds.output_daily_stats_csv(self.mock_config)
269269

270270
# Check that file was written
271-
mocked_file.assert_called_with("daily_stats.csv", "w", newline="")
271+
mocked_file.assert_called_with("git_daily_stats.csv", "w", newline="")
272272

273273
# Check that print was called
274274
self.assertTrue(mock_print.called)
275-
mock_print.assert_any_call("Daily stats saved to daily_stats.csv")
275+
mock_print.assert_any_call("Daily stats saved to git_daily_stats.csv")
276276

277277
@patch("git_py_stats.generate_cmds.run_git_command")
278278
@patch("builtins.input", return_value="")
@@ -378,7 +378,7 @@ def test_output_daily_stats_csv_io_error(self, mock_print, mock_input, mock_run_
378378
with patch("builtins.open", side_effect=IOError("Disk full")):
379379
generate_cmds.output_daily_stats_csv(self.mock_config)
380380

381-
mock_print.assert_any_call("Failed to write to daily_stats.csv: Disk full")
381+
mock_print.assert_any_call("Failed to write to git_daily_stats.csv: Disk full")
382382

383383
@patch("git_py_stats.generate_cmds.run_git_command")
384384
@patch("builtins.print")

git_py_stats/tests/test_interactive_mode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_option_8(self, mock_branch_tree, mock_interactive_menu):
8989
def test_option_9(self, mock_branches_by_date, mock_interactive_menu):
9090
mock_interactive_menu.side_effect = ["9", ""]
9191
interactive_mode.handle_interactive_mode(self.mock_config)
92-
mock_branches_by_date.assert_called_once_with()
92+
mock_branches_by_date.assert_called_once_with(self.mock_config)
9393

9494
@patch("git_py_stats.interactive_mode.interactive_menu")
9595
@patch("git_py_stats.list_cmds.contributors")

0 commit comments

Comments
 (0)