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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ tags
# macOS-related
.DS_Store

# Python-based virtual environment I use for publishing
.venv-publish

# Shamelessly taken from here because I'm lazy:
# https://github.com/github/gitignore/blob/main/Python.gitignore

Expand Down
7 changes: 4 additions & 3 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
include LICENSE
include README.md
include TODO.md
include CODE_OF_CONDUCT.md
include CONTRIBUTING.md
recursive-include git_py_stats *.py
recursive-include man *
global-exclude *.pyc __pycache__/
recursive-include man *.1
global-exclude *.pyc __pycache__/ *.py[cod]
prune git_py_stats/tests

12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,22 @@ export _GIT_MERGE_VIEW="exclusive"
### Git Branch

You can set the variable `_GIT_BRANCH` to set the branch of the stats.
Works with commands `--git-stats-by-branch` and `--csv-output-by-branch`.
Works with command `--csv-output-by-branch` only currently.

```bash
export _GIT_BRANCH="master"
```

### Ignore Authors

You can set the variable `_GIT_IGNORE_AUTHORS` to filter out specific
authors. It will currently work with the "Code reviewers", "New contributors",
"All branches", and "Output daily stats by branch in CSV format" options.

```bash
export _GIT_IGNORE_AUTHORS="(author@examle.com|username)"
```

### Sorting Contribution Stats

You can sort contribution stats by field `name`, `commits`, `insertions`,
Expand Down
26 changes: 0 additions & 26 deletions TODO.md

This file was deleted.

43 changes: 42 additions & 1 deletion git_py_stats/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,34 @@
"""

import os
import re
from datetime import datetime
from typing import Dict, Union, Optional
from typing import Dict, Union, Optional, Callable
from git_py_stats.git_operations import run_git_command


def _build_author_exclusion_filter(pattern: str) -> Callable[[str], bool]:
"""
Compile a string of authors that tells you whether an author
should be ignored based on a user-configured environment
variable.

Args:
pattern (str): A regex (Example: "(user@example.com|Some User)").
No flags are injected automatically, but users can
include them for case-insensitive matches.

Returns:
Callable[[str], bool]: Input string 's' that matches the pattern to be
ignored. False otherwise.
"""
pattern = (pattern or "").strip()
if not pattern:
return lambda _s: False
rx = re.compile(pattern)
return lambda s: bool(rx.search(s or ""))


def _parse_git_sort_by(raw: str) -> tuple[str, str]:
"""
Helper function for handling sorting features for contribution stats.
Expand Down Expand Up @@ -81,11 +104,14 @@ def get_config() -> Dict[str, Union[str, int]]:
- 'enable' to use the user's default merge view from the conf.
Default is usually to show both regular and merge commits.
- Any other value defaults to '--no-merges' currently.
_GIT_BRANCH (str): Sets branch you want to target for some stats.
Default is empty which falls back to the current branch you're on.
_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.
_GIT_IGNORE_AUTHORS (str): Defines authors to ignore. Default is empty.
_MENU_THEME (str): Toggles between the default theme and legacy theme.
- 'legacy' to set the legacy theme
- 'none' to disable the menu theme
Expand All @@ -100,8 +126,12 @@ def get_config() -> Dict[str, Union[str, int]]:
- 'until' (str): Git command option for the end date.
- 'pathspec' (str): Git command option for pathspec.
- 'merges' (str): Git command option for merge commit view strategy.
- 'branch' (str): Git branch name.
- 'limit' (int): Git log output limit.
- 'log_options' (str): Additional git log options.
- 'days' (str): Number of days for the heatmap.
- 'sort_by' (str): Sort by field and sort direction (asc/desc).
- 'ignore_authors': (str): Any author(s) to ignore.
- 'menu_theme' (str): Menu theme color.
"""
config: Dict[str, Union[str, int]] = {}
Expand Down Expand Up @@ -146,6 +176,13 @@ def get_config() -> Dict[str, Union[str, int]]:
else:
config["merges"] = "--no-merges"

# _GIT_BRANCH
git_branch: Optional[str] = os.environ.get("_GIT_BRANCH")
if git_branch:
config["branch"] = git_branch
else:
config["branch"] = ""

# _GIT_LIMIT
git_limit: Optional[str] = os.environ.get("_GIT_LIMIT")
if git_limit:
Expand Down Expand Up @@ -184,6 +221,10 @@ def get_config() -> Dict[str, Union[str, int]]:
config["sort_by"] = sort_by
config["sort_dir"] = sort_dir

# _GIT_IGNORE_AUTHORS
ignore_authors_pattern: Optional[str] = os.environ.get("_GIT_IGNORE_AUTHORS")
config["ignore_authors"] = _build_author_exclusion_filter(ignore_authors_pattern)

# _MENU_THEME
menu_theme: Optional[str] = os.environ.get("_MENU_THEME")
if menu_theme == "legacy":
Expand Down
83 changes: 67 additions & 16 deletions git_py_stats/generate_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,11 @@ def output_daily_stats_csv(config: Dict[str, Union[str, int]]) -> None:
until = config.get("until", "")
log_options = config.get("log_options", "")
pathspec = config.get("pathspec", "")
branch = config.get("branch", "")
ignore_authors = config.get("ignore_authors", lambda _s: False)

branch = input("Enter branch name (leave empty for current branch): ")
if not branch:
branch = input("Enter branch name (leave empty for current branch): ")

# Original command:
# git -c log.showSignature=false log ${_branch} --use-mailmap $_merges --numstat \
Expand All @@ -478,22 +481,70 @@ def output_daily_stats_csv(config: Dict[str, Union[str, int]]) -> None:
cmd = [arg for arg in cmd if arg]

output = run_git_command(cmd)
if output:
dates = output.split("\n")
counter = collections.Counter(dates)
filename = "daily_stats.csv"
try:
with open(filename, "w", newline="") as csvfile:
fieldnames = ["Date", "Commits"]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for date, count in sorted(counter.items()):
writer.writerow({"Date": date, "Commits": count})
print(f"Daily stats saved to {filename}")
except IOError as e:
print(f"Failed to write to {filename}: {e}")
else:

# Exit early if no output valid
if not output:
print("No data available.")
return

# NOTE: This has to be expanded to handle the new ability to ignore
# authors, but there might be a better way to handle this...
kept_lines = []
current_block = []
current_ignored = False
have_seen_author = False

for line in output.splitlines():
# New commit starts
if line.startswith("commit "):
# Flush the previous block
if current_block and not current_ignored:
kept_lines.extend(current_block)
# Reset for the next block
current_block = [line]
current_ignored = False
have_seen_author = False
continue

# Only check author once per block
if not have_seen_author and line.startswith("Author: "):
author_line = line[len("Author: ") :].strip()
name = author_line
email = ""
if "<" in author_line and ">" in author_line:
name = author_line.split("<", 1)[0].strip()
email = author_line.split("<", 1)[1].split(">", 1)[0].strip()

# If any form matches (name or email), drop the whole block
if (
ignore_authors(author_line)
or ignore_authors(name)
or (email and ignore_authors(email))
):
current_ignored = True
have_seen_author = True
current_block.append(line)

# Flush the last block
if current_block and not current_ignored:
kept_lines.extend(current_block)

# Found nothing worth keeping? Just exit then
if not kept_lines:
print("No data available.")
return

counter = collections.Counter(kept_lines)
filename = "git_daily_stats.csv"
try:
with open(filename, "w", newline="") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=["Date", "Commits"])
writer.writeheader()
for text, count in sorted(counter.items()):
writer.writerow({"Date": text, "Commits": count})
print(f"Daily stats saved to {filename}")
except IOError as e:
print(f"Failed to write to {filename}: {e}")


# TODO: This doesn't match the original functionality as it uses some pretty
Expand Down
2 changes: 1 addition & 1 deletion git_py_stats/interactive_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def handle_interactive_mode(config: Dict[str, Union[str, int]]) -> None:
"6": lambda: generate_cmds.output_daily_stats_csv(config),
"7": lambda: generate_cmds.save_git_log_output_json(config),
"8": lambda: list_cmds.branch_tree(config),
"9": list_cmds.branches_by_date,
"9": lambda: list_cmds.branches_by_date(config),
"10": lambda: list_cmds.contributors(config),
"11": lambda: list_cmds.new_contributors(config, input("Enter cutoff date (YYYY-MM-DD): ")),
"12": lambda: list_cmds.git_commits_per_author(config),
Expand Down
68 changes: 49 additions & 19 deletions git_py_stats/list_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,24 +79,32 @@ def branch_tree(config: Dict[str, Union[str, int]]) -> None:
print("No data available.")


def branches_by_date() -> None:
def branches_by_date(config: Dict[str, Union[str, int]]) -> None:
"""
Lists branches sorted by the latest commit date.

Args:
None
config: Dict[str, Union[str, int]]: Config dictionary holding env vars.

Returns:
None
"""

# Grab the config options from our config.py.
ignore_authors = config.get("ignore_authors", lambda _s: False)

# Original command:
# git for-each-ref --sort=committerdate refs/heads/ \
# --format='[%(authordate:relative)] %(authorname) %(refname:short)' | cat -n
# TODO: Wouldn't git log --pretty=format:'%ad' --date=short be better here?
# Then we could pipe it through sort, uniq -c, sort -nr, etc.
# Possibly feed back into the parent project
format_str = "[%(authordate:relative)] %(authorname) %(refname:short)"

# Include the email so we can filter based off it, but keep the visible
# part the same as before.
visible_fmt = "[%(authordate:relative)] %(authorname) %(refname:short)"
format_str = f"{visible_fmt}|%(authoremail)"

cmd = [
"git",
"for-each-ref",
Expand All @@ -106,19 +114,35 @@ def branches_by_date() -> None:
]

output = run_git_command(cmd)
if output:
# Split the output into lines
lines = output.split("\n")
if not output:
print("No commits found.")
return

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

# Output numbered lines
print("All branches (sorted by most recent commit):\n")
for line in numbered_lines:
print(f"\t{line}")
else:
# Filter by either email or the visible chunk.
if ignore_authors(email) or ignore_authors(visible):
continue

visible_lines.append(visible)

if not visible_lines:
print("No commits found.")
return

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


def contributors(config: Dict[str, Union[str, int]]) -> None:
Expand Down Expand Up @@ -213,6 +237,7 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
until = config.get("until", "")
log_options = config.get("log_options", "")
pathspec = config.get("pathspec", "")
ignore_authors = config.get("ignore_authors", lambda _s: False)

# Original command:
# git -c log.showSignature=false log --use-mailmap $_merges \
Expand Down Expand Up @@ -245,6 +270,9 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
try:
email, timestamp = line.split("|")
timestamp = int(timestamp)
# Skip ignored by email
if ignore_authors(email):
continue
# If the contributor is not in the dictionary or the current timestamp is earlier
if email not in contributors_dict or timestamp < contributors_dict[email]:
contributors_dict[email] = timestamp
Expand Down Expand Up @@ -283,12 +311,14 @@ def new_contributors(config: Dict[str, Union[str, int]], new_date: str) -> None:
name_cmd = [arg for arg in name_cmd if arg]

# Grab name + email if we can. Otherwise, just grab email
name = run_git_command(name_cmd)
if name:
new_contributors_list.append((name, email))
else:
new_contributors_list.append(("", email))

# while also making sure to ignore any authors that may be
# in our ignore_author env var
name = (run_git_command(name_cmd) or "").strip()
combo = f"{name} <{email}>" if name else f"<{email}>"
if ignore_authors(email) or ignore_authors(name) or ignore_authors(combo):
continue

new_contributors_list.append((name, email))
# Sort the list alphabetically by name to match the original
# and print all of this out
if new_contributors_list:
Expand Down
Loading