├── tests ├── __init__.py ├── tests_utils.py └── tests_gitfame.py ├── .dockerignore ├── .meta ├── requirements-build.txt └── .git-fame.1.md ├── .github ├── FUNDING.yml ├── CODEOWNERS └── workflows │ ├── comment-bot.yml │ └── test.yml ├── gitfame ├── __main__.py ├── __init__.py ├── _utils.py ├── git-fame.1 └── _gitfame.py ├── Dockerfile ├── .gitignore ├── .zenodo.json ├── LICENCE ├── snapcraft.yaml ├── tox.ini ├── .pre-commit-config.yaml ├── git-fame_completion.bash ├── Makefile ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/*.whl 3 | -------------------------------------------------------------------------------- /.meta/requirements-build.txt: -------------------------------------------------------------------------------- 1 | py-make>=0.1.0 2 | twine 3 | build 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: casperdcl 2 | custom: https://cdcl.ml/sponsor 3 | -------------------------------------------------------------------------------- /gitfame/__main__.py: -------------------------------------------------------------------------------- 1 | from ._gitfame import main # pragma: no cover, yapf: disable 2 | 3 | main() # pragma: no cover 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | RUN apk update && apk add --no-cache git 3 | COPY dist/*.whl . 4 | RUN pip install -U $(ls *.whl)[full] && rm *.whl 5 | ENTRYPOINT ["git-fame", "/repo"] 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Require maintainer's :+1: for changes to the .github/ repo-config files 2 | # mainly due to https://github.com/probot/settings privilege escalation 3 | .github/* @github/pages 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.so 3 | __pycache__/ 4 | 5 | # Packages 6 | /gitfame/_dist_ver.py 7 | /*.egg*/ 8 | /build/ 9 | /dist/ 10 | /git-fame_*.snap 11 | 12 | # Unit test / coverage reports 13 | /.tox/ 14 | /.coverage* 15 | /coverage.xml 16 | /.pytest_cache/ 17 | -------------------------------------------------------------------------------- /gitfame/__init__.py: -------------------------------------------------------------------------------- 1 | from ._gitfame import (__author__, __copyright__, __date__, __licence__, __license__, __version__, 2 | main) 3 | 4 | __all__ = [ 5 | 'main', '__author__', '__date__', '__licence__', '__copyright__', '__version__', '__license__'] 6 | -------------------------------------------------------------------------------- /.zenodo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "git-fame: Pretty-print `git` repository collaborators sorted by contributions", 3 | "keywords": [ 4 | "git", "blame", "git-blame", "git-log", "code-analysis", "cost", "loc", "author", 5 | "commit", "shortlog", "ls-files"], 6 | "creators": [ 7 | {"name": "da Costa-Luis, Casper O.", "orcid": "0000-0002-7211-1557"}] 8 | } 9 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | * files: * 2 | MPLv2.0 2016-2025 (c) Casper da Costa-Luis 3 | [casperdcl](https://github.com/casperdcl). 4 | 5 | 6 | Mozilla Public Licence (MPL) v. 2.0 - Exhibit A 7 | ----------------------------------------------- 8 | 9 | This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 10 | If a copy of the MPL was not distributed with this project, You can obtain one at https://mozilla.org/MPL/2.0/. 11 | -------------------------------------------------------------------------------- /.github/workflows/comment-bot.yml: -------------------------------------------------------------------------------- 1 | # runs on any comment matching the format `/tag ` 2 | name: Comment Bot 3 | on: 4 | issue_comment: {types: [created]} 5 | pull_request_review_comment: {types: [created]} 6 | jobs: 7 | tag: 8 | runs-on: ubuntu-latest 9 | permissions: {contents: write, pull-requests: write, issues: write} 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | token: ${{ secrets.GH_TOKEN || github.token }} 15 | - uses: casperdcl/comment-bot@v1 16 | with: 17 | token: ${{ secrets.GH_TOKEN || github.token }} 18 | -------------------------------------------------------------------------------- /.meta/.git-fame.1.md: -------------------------------------------------------------------------------- 1 | % GIT-FAME(1) git-fame User Manuals 2 | % Casper da Costa-Luis (https://github.com/casperdcl) 3 | % 2016-2025 4 | 5 | # NAME 6 | 7 | git-fame - Pretty-print `git` repository collaborators sorted by contributions. 8 | 9 | # SYNOPSIS 10 | 11 | git-fame [\--help | *options*] [<*gitdir*>...] 12 | 13 | # DESCRIPTION 14 | 15 | See https://github.com/casperdcl/git-fame. 16 | 17 | Probably not necessary on UNIX systems: 18 | 19 | ```sh 20 | git config --global alias.fame "!python -m gitfame" 21 | ``` 22 | 23 | For example, to print statistics regarding all source files in a C++/CUDA 24 | repository (``*.c/h/t(pp), *.cu(h)``), carefully handling whitespace and line 25 | copies: 26 | 27 | ```sh 28 | git fame --incl '\.[cht][puh]{0,2}$' -twMC 29 | ``` 30 | 31 | # OPTIONS 32 | 33 | \ 34 | : Git directory [default: ./]. 35 | May be specified multiple times to aggregate across 36 | multiple repositories. 37 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: git-fame 2 | summary: Pretty-print `git` repository collaborators sorted by contributions 3 | description: https://github.com/casperdcl/git-fame 4 | adopt-info: git-fame 5 | grade: stable 6 | confinement: strict 7 | base: core24 8 | license: MPL-2.0 9 | parts: 10 | git-fame: 11 | plugin: python 12 | python-packages: [pyyaml] 13 | source: . 14 | build-snaps: 15 | - snapd 16 | build-packages: [git] 17 | stage-packages: [git] 18 | override-build: | 19 | craftctl default 20 | cp $SNAPCRAFT_PART_BUILD/git-fame_completion.bash $SNAPCRAFT_PART_INSTALL/completion.sh 21 | override-stage: | 22 | craftctl default 23 | craftctl set version=$(bin/python3 -m gitfame --version) 24 | apps: 25 | git-fame: 26 | command: bin/git-fame 27 | completer: completion.sh 28 | plugs: [home] 29 | environment: 30 | GIT_EXEC_PATH: $SNAP/libexec/git-core 31 | GIT_TEMPLATE_DIR: $SNAP/usr/share/git-core/templates 32 | GIT_CONFIG_NOSYSTEM: "true" 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist=py{38,39,310,311,312,313,py3}, check 8 | isolated_build=True 9 | 10 | [gh-actions] 11 | python= 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 3.12: py312 17 | 3.13: py313 18 | 19 | [core] 20 | deps= 21 | pytest 22 | pytest-cov 23 | pytest-timeout 24 | coverage 25 | coveralls 26 | codecov 27 | commands= 28 | - coveralls 29 | codecov -X pycov -e TOXENV 30 | 31 | [testenv] 32 | passenv=TOXENV,CI,GITHUB_*,CODECOV_*,COVERALLS_*,HOME 33 | deps= 34 | {[core]deps} 35 | argopt 36 | tabulate 37 | tqdm 38 | pyyaml 39 | commands= 40 | pytest --cov=gitfame --cov-report=xml --cov-report=term 41 | {[core]commands} 42 | 43 | [testenv:check] 44 | deps= 45 | build 46 | twine 47 | py-make>=0.1.0 48 | commands= 49 | {envpython} -m build 50 | {envpython} -m twine check dist/* 51 | {envpython} -m pymake -h 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-case-conflict 9 | - id: check-docstring-first 10 | - id: check-executables-have-shebangs 11 | - id: check-toml 12 | - id: check-merge-conflict 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: end-of-file-fixer 16 | - id: mixed-line-ending 17 | - id: sort-simple-yaml 18 | - id: trailing-whitespace 19 | - repo: local 20 | hooks: 21 | - id: todo 22 | name: Check TODO 23 | language: pygrep 24 | entry: WIP 25 | args: [-i] 26 | types: [text] 27 | exclude: ^(.pre-commit-config.yaml|.github/workflows/test.yml)$ 28 | - id: pytest 29 | name: Run tests 30 | language: python 31 | entry: pytest 32 | args: [-qq, --durations=1, -n=auto] 33 | types: [python] 34 | pass_filenames: false 35 | additional_dependencies: 36 | - pytest-timeout 37 | - pytest-xdist 38 | - argopt 39 | - pyyaml 40 | - tabulate 41 | - tqdm 42 | - repo: https://github.com/PyCQA/flake8 43 | rev: 7.3.0 44 | hooks: 45 | - id: flake8 46 | args: [-j8] 47 | additional_dependencies: 48 | - flake8-broken-line 49 | - flake8-bugbear 50 | - flake8-comprehensions 51 | - flake8-debugger 52 | - flake8-isort 53 | - flake8-pyproject 54 | - flake8-string-format 55 | - repo: https://github.com/asottile/pyupgrade 56 | rev: v3.20.0 57 | hooks: 58 | - id: pyupgrade 59 | args: [--py38-plus] 60 | - repo: https://github.com/google/yapf 61 | rev: v0.43.0 62 | hooks: 63 | - id: yapf 64 | args: [-i] 65 | additional_dependencies: [toml] 66 | - repo: https://github.com/PyCQA/isort 67 | rev: 6.0.1 68 | hooks: 69 | - id: isort 70 | -------------------------------------------------------------------------------- /git-fame_completion.bash: -------------------------------------------------------------------------------- 1 | _git_fame() 2 | { 3 | local cur prv 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prv="${COMP_WORDS[COMP_CWORD-1]}" 6 | case ${COMP_CWORD} in 7 | 1) 8 | COMPREPLY=($(compgen -W "fame" "${cur}")) 9 | ;; 10 | *) 11 | case ${prv} in 12 | --sort) 13 | COMPREPLY=($(compgen -W 'loc commits files hours months' -- ${cur})) 14 | ;; 15 | --cost) 16 | COMPREPLY=($(compgen -W 'months cocomo hours commits' -- ${cur})) 17 | ;; 18 | --loc) 19 | COMPREPLY=($(compgen -W 'surviving insertions deletions ins,del' -- ${cur})) 20 | ;; 21 | --format) 22 | COMPREPLY=($(compgen -W 'pipe markdown yaml json csv tsv svg tabulate' -- ${cur})) 23 | ;; 24 | --show) 25 | COMPREPLY=($(compgen -W 'name email name,email' -- ${cur})) 26 | ;; 27 | --log) 28 | COMPREPLY=($(compgen -W 'FATAL CRITICAL ERROR WARNING INFO DEBUG NOTSET' -- ${cur})) 29 | ;; 30 | --branch) 31 | COMPREPLY=($(compgen -W "$(git branch | sed 's/*/ /')" -- ${cur})) 32 | ;; 33 | --ignore-revs-file) 34 | COMPREPLY=($(compgen -f -- "${cur}")) 35 | ;; 36 | --manpath) 37 | COMPREPLY=($(compgen -d -- "${cur}")) 38 | ;; 39 | --incl|--excl|--since|--ignore-rev|--until|--min) 40 | COMPREPLY=( ) 41 | ;; 42 | *) 43 | if [ ${COMP_WORDS[1]} == fame ]; then 44 | COMPREPLY=($(compgen -dW '-h --help -v --version --cost --branch --since --until --sort --loc --incl --excl -R --recurse -n --no-regex -s --silent-progress --warn-binary -t --bytype -w --ignore-whitespace --show -e --show-email --enum -M -C --ignore-rev --ignore-revs-file --format --manpath --log' -- ${cur})) 45 | fi 46 | ;; 47 | esac 48 | ;; 49 | esac 50 | } 51 | complete -F _git_fame git-fame 52 | -------------------------------------------------------------------------------- /tests/tests_utils.py: -------------------------------------------------------------------------------- 1 | from gitfame import _utils 2 | 3 | 4 | def test_tighten(): 5 | """Test (grid) table compression""" 6 | 7 | orig_tab = ''' 8 | +------------------------+-----+------+------+----------------------+ 9 | | Author | loc | coms | fils | distribution | 10 | +========================+=====+======+======+======================+ 11 | | Casper da Costa-Luis | 719 | 35 | 11 | 93.5/ 100/84.6 | 12 | +------------------------+-----+------+------+----------------------+ 13 | | Not Committed Yet | 50 | 0 | 2 | 6.5/ 0.0/15.4 | 14 | +------------------------+-----+------+------+----------------------+ 15 | ''' 16 | 17 | # compress whitespace 18 | assert (_utils.tighten(orig_tab, max_width=80) == '''\ 19 | +----------------------+-----+------+------+----------------+ 20 | | Author | loc | coms | fils | distribution | 21 | +======================+=====+======+======+================+ 22 | | Casper da Costa-Luis | 719 | 35 | 11 | 93.5/ 100/84.6 | 23 | | Not Committed Yet | 50 | 0 | 2 | 6.5/ 0.0/15.4 | 24 | +----------------------+-----+------+------+----------------+''') 25 | 26 | # compress first column 27 | assert (_utils.tighten(orig_tab, max_width=47) == '''\ 28 | +--------+-----+------+------+----------------+ 29 | | Author | loc | coms | fils | distribution | 30 | +========+=====+======+======+================+ 31 | | Casper | 719 | 35 | 11 | 93.5/ 100/84.6 | 32 | | Not Com| 50 | 0 | 2 | 6.5/ 0.0/15.4 | 33 | +--------+-----+------+------+----------------+''') 34 | 35 | # too small width - no first column compression 36 | assert (_utils.tighten(orig_tab, max_width=35) == _utils.tighten(orig_tab)) 37 | 38 | 39 | def test_fext(): 40 | """Test detection of file extensions""" 41 | assert (_utils.fext('foo/bar.baz') == 'baz') 42 | assert (_utils.fext('foo/.baz') == 'baz') 43 | assert (_utils.fext('foo/bar') == '') 44 | 45 | 46 | def test_Max(): 47 | """Test max with defaults""" 48 | assert (_utils.Max(range(10), -1) == 9) 49 | assert (_utils.Max(range(0), -1) == -1) 50 | 51 | 52 | def test_integer_stats(): 53 | """Test integer representations""" 54 | assert (_utils.int_cast_or_len(range(10)) == 10) 55 | assert (_utils.int_cast_or_len('90 foo') == 6) 56 | assert (_utils.int_cast_or_len('90') == 90) 57 | 58 | 59 | def test_print(): 60 | """Test printing of unicode""" 61 | _utils.print_unicode("\x81") 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # IMPORTANT: for compatibility with `python -m pymake [alias]`, ensure: 2 | # 1. Every alias is preceded by @[+]make (eg: @make alias) 3 | # 2. A maximum of one @make alias or command per line 4 | # see: https://github.com/tqdm/py-make/issues/1 5 | 6 | .PHONY: 7 | alltests 8 | all 9 | flake8 10 | test 11 | pytest 12 | testsetup 13 | testcoverage 14 | testtimer 15 | distclean 16 | coverclean 17 | prebuildclean 18 | clean 19 | toxclean 20 | install_build 21 | install_dev 22 | install 23 | build 24 | buildupload 25 | pypi 26 | docker 27 | help 28 | none 29 | run 30 | 31 | help: 32 | @python -m pymake -p 33 | 34 | alltests: 35 | @+make testcoverage 36 | @+make flake8 37 | @+make testsetup 38 | 39 | all: 40 | @+make alltests 41 | @+make build 42 | 43 | flake8: 44 | @+pre-commit run -a flake8 45 | 46 | test: 47 | tox --skip-missing-interpreters -p all 48 | 49 | pytest: 50 | pytest 51 | 52 | testsetup: 53 | @make gitfame/git-fame.1 54 | @make help 55 | 56 | testcoverage: 57 | @make coverclean 58 | pytest --cov=gitfame --cov-report=xml --cov-report=term --cov-fail-under=80 59 | 60 | testtimer: 61 | pytest 62 | 63 | gitfame/git-fame.1: .meta/.git-fame.1.md gitfame/_gitfame.py 64 | python -c 'import gitfame; print(gitfame._gitfame.__doc__.rstrip())' |\ 65 | grep -A999 '^Options:$$' | tail -n+2 |\ 66 | sed -r -e 's/\\/\\\\/g' \ 67 | -e 's/^ (--\S+=)<(\S+)>\s+(.*)$$/\n\\\1*\2*\n: \3/' \ 68 | -e 's/^ (-., )(-\S+)\s*/\n\\\1\\\2\n: /' \ 69 | -e 's/^ (--\S+)\s+/\n\\\1\n: /' \ 70 | -e 's/^ (-.)\s+/\n\\\1\n: /' |\ 71 | cat "$<" - |\ 72 | pandoc -o "$@" -s -t man 73 | 74 | distclean: 75 | @+make coverclean 76 | @+make prebuildclean 77 | @+make clean 78 | prebuildclean: 79 | @+python -c "import shutil; shutil.rmtree('build', True)" 80 | @+python -c "import shutil; shutil.rmtree('dist', True)" 81 | @+python -c "import shutil; shutil.rmtree('git_fame.egg-info', True)" 82 | @+python -c "import shutil; shutil.rmtree('.eggs', True)" 83 | @+python -c "import os; os.remove('gitfame/_dist_ver.py') if os.path.exists('gitfame/_dist_ver.py') else None" 84 | coverclean: 85 | @+python -c "import os; os.remove('.coverage') if os.path.exists('.coverage') else None" 86 | @+python -c "import os, glob; [os.remove(i) for i in glob.glob('.coverage.*')]" 87 | @+python -c "import shutil; shutil.rmtree('gitfame/__pycache__', True)" 88 | @+python -c "import shutil; shutil.rmtree('tests/__pycache__', True)" 89 | clean: 90 | @+python -c "import os, glob; [os.remove(i) for i in glob.glob('*.py[co]')]" 91 | @+python -c "import os, glob; [os.remove(i) for i in glob.glob('gitfame/*.py[co]')]" 92 | @+python -c "import os, glob; [os.remove(i) for i in glob.glob('tests/*.py[co]')]" 93 | toxclean: 94 | @+python -c "import shutil; shutil.rmtree('.tox', True)" 95 | 96 | install: 97 | python -m pip install . 98 | install_dev: 99 | python -m pip install -e . 100 | install_build: 101 | python -m pip install -r .meta/requirements-build.txt 102 | 103 | build: 104 | @make prebuildclean 105 | @make testsetup 106 | python -m build 107 | python -m twine check dist/* 108 | 109 | pypi: 110 | python -m twine upload dist/* 111 | 112 | buildupload: 113 | @make build 114 | @make pypi 115 | 116 | docker: 117 | @make build 118 | docker build . -t casperdcl/git-fame 119 | docker tag casperdcl/git-fame:latest casperdcl/git-fame:$(shell docker run --rm casperdcl/git-fame -v) 120 | none: 121 | # used for unit testing 122 | 123 | run: 124 | python -Om gitfame 125 | -------------------------------------------------------------------------------- /gitfame/_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | import sys 4 | from functools import partial 5 | 6 | from tqdm import tqdm as tqdm_std 7 | from tqdm.utils import _screen_shape_wrapper 8 | 9 | try: 10 | from threading import RLock 11 | except ImportError: 12 | tqdm = tqdm_std 13 | else: 14 | tqdm_std.set_lock(RLock()) 15 | tqdm = partial(tqdm_std, lock_args=(False,)) 16 | 17 | __author__ = "Casper da Costa-Luis " 18 | __date__ = "2016-2025" 19 | __licence__ = "[MPLv2.0](https://mozilla.org/MPL/2.0/)" 20 | __all__ = [ 21 | "TERM_WIDTH", "int_cast_or_len", "Max", "fext", "tqdm", "tighten", "check_output", 22 | "print_unicode", "Str"] 23 | __copyright__ = ' '.join(("Copyright (c)", __date__, __author__, __licence__)) 24 | __license__ = __licence__ # weird foreign language 25 | 26 | log = logging.getLogger(__name__) 27 | if not (TERM_WIDTH := _screen_shape_wrapper()(sys.stdout)[0]): 28 | # non interactive pipe 29 | TERM_WIDTH = 256 30 | 31 | 32 | class TqdmStream: 33 | @classmethod 34 | def write(cls, msg): 35 | tqdm_std.write(msg, end='') 36 | 37 | 38 | def check_output(*a, **k): 39 | log.debug(' '.join(a[0][3:])) 40 | k.setdefault('stdout', subprocess.PIPE) 41 | return subprocess.Popen(*a, **k).communicate()[0].decode('utf-8', errors='replace') 42 | 43 | 44 | def blank_col(rows, i, blanks): 45 | return all(r[i] in blanks for r in rows) 46 | 47 | 48 | def tighten(t, max_width=256, blanks=' -=', seps='|+'): 49 | """Tighten (default: grid) table padding""" 50 | rows = t.strip().split('\n') 51 | i = 1 52 | curr_blank = bool() 53 | prev_blank = blank_col(rows, i - 1, blanks) 54 | len_r = len(rows[0]) 55 | while i < len_r: 56 | curr_blank = blank_col(rows, i, blanks) 57 | if prev_blank and curr_blank: 58 | rows = [r[:i - 1] + r[i:] for r in rows] 59 | len_r -= 1 60 | i -= 1 61 | prev_blank = curr_blank 62 | i += 1 63 | 64 | if len_r > max_width: 65 | have_first_line = False 66 | for i in range(len_r): 67 | if blank_col(rows, i, seps): 68 | if have_first_line: 69 | if i > len_r - max_width: 70 | return '\n'.join(r[:i - len_r + max_width] + r[i:] 71 | for r in rows[:3] + rows[3::2] + [rows[-1]]) 72 | break 73 | else: 74 | have_first_line = True 75 | 76 | return '\n'.join(rows[:3] + rows[3::2] + [rows[-1]]) 77 | 78 | 79 | def fext(fn): 80 | """File extension""" 81 | res = fn.split('.') 82 | return res[-1] if len(res) > 1 else '' 83 | 84 | 85 | def int_cast_or_len(i): 86 | """ 87 | >>> int_cast_or_len(range(10)) 88 | 10 89 | >>> int_cast_or_len('90 foo') 90 | 6 91 | >>> int_cast_or_len('90') 92 | 90 93 | 94 | """ 95 | try: 96 | return int(i) 97 | except (ValueError, TypeError): 98 | return len(i) 99 | 100 | 101 | def Max(it, empty_default=0): 102 | """ 103 | >>> Max(range(10), -1) 104 | 9 105 | >>> Max(range(0), -1) 106 | -1 107 | 108 | """ 109 | try: 110 | return max(it) 111 | except ValueError as e: 112 | if 'empty' in str(e): 113 | return empty_default 114 | raise # pragma: no cover 115 | 116 | 117 | def print_unicode(msg, end='\n', err='?'): 118 | """print `msg`, replacing unicode characters with `err` upon failure""" 119 | for c in msg: 120 | try: 121 | print(c, end='') 122 | except UnicodeEncodeError: 123 | print(err, end='') 124 | print('', end=end) 125 | 126 | 127 | def Str(i): 128 | """return `'%g' % i` if possible, else `str(i)`""" 129 | try: 130 | return '%g' % i 131 | except TypeError: 132 | return str(i) 133 | 134 | 135 | def merge_stats(left, right): 136 | """Add `right`'s values to `left` (modifies `left` in-place)""" 137 | for k, val in right.items(): 138 | if isinstance(val, int): 139 | left[k] = left.get(k, 0) + val 140 | elif hasattr(val, 'extend'): 141 | left[k].extend(val) 142 | elif hasattr(val, 'update'): 143 | left[k].update(val) 144 | else: 145 | raise TypeError(val) 146 | return left 147 | -------------------------------------------------------------------------------- /gitfame/git-fame.1: -------------------------------------------------------------------------------- 1 | .\" Automatically generated by Pandoc 3.7.0.1 2 | .\" 3 | .TH "GIT\-FAME" "1" "2016\-2025" "git\-fame User Manuals" 4 | .SH NAME 5 | git\-fame \- Pretty\-print \f[CR]git\f[R] repository collaborators 6 | sorted by contributions. 7 | .SH SYNOPSIS 8 | git\-fame [\-\-help | \f[I]options\f[R]] [<\f[I]gitdir\f[R]>\&...] 9 | .SH DESCRIPTION 10 | See https://github.com/casperdcl/git\-fame. 11 | .PP 12 | Probably not necessary on UNIX systems: 13 | .IP 14 | .EX 15 | git config \-\-global alias.fame \(dq!python \-m gitfame\(dq 16 | .EE 17 | .PP 18 | For example, to print statistics regarding all source files in a 19 | C++/CUDA repository (\f[CR]*.c/h/t(pp), *.cu(h)\f[R]), carefully 20 | handling whitespace and line copies: 21 | .IP 22 | .EX 23 | git fame \-\-incl \(aq\(rs.[cht][puh]{0,2}$\(aq \-twMC 24 | .EE 25 | .SH OPTIONS 26 | .TP 27 | 28 | Git directory [default: ./]. 29 | May be specified multiple times to aggregate across multiple 30 | repositories. 31 | .TP 32 | \-h, \-\-help 33 | Print this help and exit. 34 | .TP 35 | \-v, \-\-version 36 | Print module version and exit. 37 | .TP 38 | \-\-branch=\f[I]b\f[R] 39 | Branch or tag [default: HEAD] up to which to check. 40 | .TP 41 | \-\-sort=\f[I]key\f[R] 42 | [default: loc]|commits|files|hours|months. 43 | .TP 44 | \-\-min=\f[I]val\f[R] 45 | Minimum value (of \f[CR]\-\-sort\f[R] key) to show [default: 0:int]. 46 | .TP 47 | \-\-loc=\f[I]type\f[R] 48 | surv(iving)|ins(ertions)|del(etions) What \f[CR]loc\f[R] represents. 49 | Use `ins,del' to count both. 50 | defaults to `surviving' unless \f[CR]\-\-cost\f[R] is specified. 51 | .TP 52 | \-\-excl=\f[I]f\f[R] 53 | Excluded files (default: None). 54 | In no\-regex mode, may be a comma\-separated list. 55 | Escape (\(rs,) for a literal comma (may require \(rs\(rs, in shell). 56 | .TP 57 | \-\-incl=\f[I]f\f[R] 58 | Included files [default: .*]. 59 | See \f[CR]\-\-excl\f[R] for format. 60 | .TP 61 | \-\-since=\f[I]date\f[R] 62 | Date from which to check. 63 | Can be absolute (eg: 1970\-01\-31) or relative to now (eg: 3.weeks). 64 | .TP 65 | \-\-until=\f[I]date\f[R] 66 | Date to which to check. 67 | See \f[CR]\-\-since\f[R] for format. 68 | .TP 69 | \-\-cost=\f[I]method\f[R] 70 | Include time cost in person\-months (COCOMO) or person\-hours (based on 71 | commit times). 72 | Methods: month(s)|cocomo|hour(s)|commit(s). 73 | May be multiple comma\-separated values. 74 | Alters \f[CR]\-\-loc\f[R] default to imply `ins' (COCOMO) or `ins,del' 75 | (hours). 76 | .TP 77 | \-R, \-\-recurse 78 | Recursively find repositories & submodules within . 79 | .TP 80 | \-n, \-\-no\-regex 81 | Assume are comma\-separated exact matches rather than regular 82 | expressions [default: False]. 83 | NB: if regex is enabled `,' is equivalent to `|'. 84 | .TP 85 | \-s, \-\-silent\-progress 86 | Suppress \f[CR]tqdm\f[R] [default: False]. 87 | .TP 88 | \-\-warn\-binary 89 | Don\(cqt silently skip files which appear to be binary data [default: 90 | False]. 91 | .TP 92 | \-\-show=\f[I]info\f[R] 93 | Author information to show [default: name]|email. 94 | Use `name,email' to show both. 95 | .TP 96 | \-e, \-\-show\-email 97 | Shortcut for \f[CR]\-\-show=email\f[R]. 98 | .TP 99 | \-\-enum 100 | Show row numbers [default: False]. 101 | .TP 102 | \-t, \-\-bytype 103 | Show stats per file extension [default: False]. 104 | .TP 105 | \-w, \-\-ignore\-whitespace 106 | Ignore whitespace when comparing the parent\(cqs version and the 107 | child\(cqs to find where the lines came from [default: False]. 108 | .TP 109 | \-M 110 | Detect intra\-file line moves and copies [default: False]. 111 | .TP 112 | \-C 113 | Detect inter\-file line moves and copies [default: False]. 114 | .TP 115 | \-\-ignore\-rev=\f[I]rev\f[R] 116 | Ignore changes made by the given revision (requires 117 | \f[CR]\-\-loc=surviving\f[R]). 118 | .TP 119 | \-\-ignore\-revs\-file=\f[I]f\f[R] 120 | Ignore revisions listed in the given file (requires 121 | \f[CR]\-\-loc=surviving\f[R]). 122 | .TP 123 | \-\-format=\f[I]format\f[R] 124 | Table format svg|[default: 125 | pipe]|md|markdown|yaml|yml|json|csv|tsv|tabulate. 126 | May require \f[CR]git\-fame[]\f[R], 127 | e.g.\ \f[CR]pip install git\-fame[yaml]\f[R]. 128 | Any \f[CR]tabulate.tabulate_formats\f[R] is also accepted. 129 | .TP 130 | \-\-manpath=\f[I]path\f[R] 131 | Directory in which to install git\-fame man pages. 132 | .TP 133 | \-\-log=\f[I]lvl\f[R] 134 | FATAL|CRITICAL|ERROR|WARN(ING)|[default: INFO]|DEBUG|NOTSET. 135 | .SH AUTHORS 136 | Casper da Costa\-Luis (https://github.com/casperdcl). 137 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | schedule: [{cron: '3 2 1 * *'}] # M H d m w (monthly at 2:03) 6 | jobs: 7 | test: 8 | if: github.event_name != 'pull_request' || !contains('OWNER,MEMBER,COLLABORATOR', github.event.pull_request.author_association) 9 | name: py${{ matrix.python }}-${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu] 13 | python: [3.8, 3.9, '3.10', 3.11, 3.12, 3.13] 14 | include: 15 | - {os: macos, python: 3.13} 16 | - {os: windows, python: 3.13} 17 | runs-on: ${{ matrix.os }}-latest 18 | defaults: {run: {shell: bash}} 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: {fetch-depth: 0} 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: Install 26 | run: pip install -U tox tox-gh-actions 27 | - name: Test 28 | run: tox -e py${PYVER/./} 29 | env: 30 | PYVER: ${{ matrix.python }} 31 | PLATFORM: ${{ matrix.os }} 32 | COVERALLS_FLAG_NAME: py${{ matrix.python }}-${{ matrix.os }} 33 | COVERALLS_PARALLEL: true 34 | COVERALLS_SERVICE_NAME: github 35 | # coveralls needs explicit token 36 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | coverage: 39 | continue-on-error: ${{ github.event_name != 'push' }} 40 | needs: test 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/setup-python@v5 44 | with: 45 | python-version: '3.x' 46 | - name: Coveralls Finished 47 | run: | 48 | pip install -U coveralls 49 | coveralls --finish || : 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | deploy: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | environment: pypi 56 | permissions: {contents: write, id-token: write, packages: write} 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: {fetch-depth: 0} 60 | - uses: actions/setup-python@v5 61 | with: 62 | python-version: '3.x' 63 | - name: Install 64 | run: | 65 | sudo apt-get install -yqq pandoc 66 | pip install -r .meta/requirements-build.txt 67 | make build 68 | - id: dist 69 | uses: casperdcl/deploy-pypi@v2 70 | with: 71 | gpg_key: ${{ secrets.GPG_KEY }} 72 | upload: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} 73 | - id: collect_assets 74 | name: Collect assets 75 | run: | 76 | if [[ $GITHUB_REF == refs/tags/v* ]]; then 77 | echo docker_tags=latest,${GITHUB_REF/refs\/tags\/v/} >> $GITHUB_OUTPUT 78 | echo snap_channel=stable,candidate,edge >> $GITHUB_OUTPUT 79 | elif test "$GITHUB_REF" = refs/heads/main; then 80 | echo docker_tags=main >> $GITHUB_OUTPUT 81 | echo snap_channel=candidate,edge >> $GITHUB_OUTPUT 82 | elif test "$GITHUB_REF" = refs/heads/devel; then 83 | echo docker_tags=devel >> $GITHUB_OUTPUT 84 | echo snap_channel=edge >> $GITHUB_OUTPUT 85 | fi 86 | - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 87 | name: Release 88 | run: | 89 | changelog=$(git log --pretty='format:%d%n- %s%n%b---' $(git tag --sort=v:refname | tail -n2 | head -n1)..HEAD) 90 | tag="${GITHUB_REF#refs/tags/}" 91 | gh release create --title "git-fame $tag stable" --draft --notes "$changelog" "$tag" dist/${{ steps.dist.outputs.whl }} dist/${{ steps.dist.outputs.whl_asc }} 92 | env: 93 | GH_TOKEN: ${{ github.token }} 94 | - uses: snapcore/action-build@v1 95 | id: snap_build 96 | - if: github.event_name == 'push' && steps.collect_assets.outputs.snap_channel 97 | uses: snapcore/action-publish@v1 98 | with: 99 | snap: ${{ steps.snap_build.outputs.snap }} 100 | release: ${{ steps.collect_assets.outputs.snap_channel }} 101 | env: 102 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_TOKEN }} 103 | - name: Docker build push 104 | uses: elgohr/Publish-Docker-Github-Action@master 105 | with: 106 | name: ${{ github.repository }} 107 | tags: ${{ steps.collect_assets.outputs.docker_tags }} 108 | password: ${{ secrets.DOCKER_PWD }} 109 | username: ${{ secrets.DOCKER_USR }} 110 | no_push: ${{ steps.collect_assets.outputs.docker_tags == '' }} 111 | - name: Docker push GitHub 112 | uses: elgohr/Publish-Docker-Github-Action@master 113 | with: 114 | name: ${{ github.repository }}/git-fame 115 | tags: ${{ steps.collect_assets.outputs.docker_tags }} 116 | password: ${{ github.token }} 117 | username: ${{ github.actor }} 118 | registry: docker.pkg.github.com 119 | no_push: ${{ steps.collect_assets.outputs.docker_tags == '' }} 120 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | version_file = "gitfame/_dist_ver.py" 7 | version_file_template = "__version__ = '{version}'\n" 8 | 9 | [tool.setuptools.packages.find] 10 | exclude = ["tests"] 11 | 12 | [project.urls] 13 | repository = "https://github.com/casperdcl/git-fame" 14 | changelog = "https://github.com/casperdcl/git-fame/releases" 15 | 16 | [project] 17 | name = "git-fame" 18 | dynamic = ["version"] 19 | authors = [{name = "Casper da Costa-Luis", email = "casper.dcl@physics.org"}] 20 | description = "Pretty-print `git` repository collaborators sorted by contributions" 21 | readme = "README.rst" 22 | requires-python = ">=3.8" 23 | keywords = ["git", "blame", "git-blame", "git-log", "code-analysis", "cost", "loc", "author", "commit", "shortlog", "ls-files"] 24 | license = {text = "MPL-2.0"} 25 | # Trove classifiers (https://pypi.org/pypi?%3Aaction=list_classifiers) 26 | classifiers = [ 27 | "Development Status :: 5 - Production/Stable", 28 | "Environment :: Console", 29 | "Environment :: MacOS X", 30 | "Environment :: Other Environment", 31 | "Environment :: Win32 (MS Windows)", 32 | "Environment :: X11 Applications", 33 | "Framework :: IPython", 34 | "Intended Audience :: Developers", 35 | "Intended Audience :: Education", 36 | "Intended Audience :: End Users/Desktop", 37 | "Intended Audience :: Other Audience", 38 | "Intended Audience :: System Administrators", 39 | "Operating System :: MacOS", 40 | "Operating System :: MacOS :: MacOS X", 41 | "Operating System :: Microsoft", 42 | "Operating System :: Microsoft :: MS-DOS", 43 | "Operating System :: Microsoft :: Windows", 44 | "Operating System :: POSIX", 45 | "Operating System :: POSIX :: BSD", 46 | "Operating System :: POSIX :: BSD :: FreeBSD", 47 | "Operating System :: POSIX :: Linux", 48 | "Operating System :: POSIX :: SunOS/Solaris", 49 | "Operating System :: Unix", 50 | "Programming Language :: Python", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.8", 53 | "Programming Language :: Python :: 3.9", 54 | "Programming Language :: Python :: 3.10", 55 | "Programming Language :: Python :: 3.11", 56 | "Programming Language :: Python :: 3.12", 57 | "Programming Language :: Python :: 3.13", 58 | "Programming Language :: Python :: 3 :: Only", 59 | "Programming Language :: Python :: Implementation", 60 | "Programming Language :: Python :: Implementation :: IronPython", 61 | "Programming Language :: Python :: Implementation :: PyPy", 62 | "Programming Language :: Unix Shell", 63 | "Topic :: Desktop Environment", 64 | "Topic :: Education :: Computer Aided Instruction (CAI)", 65 | "Topic :: Education :: Testing", 66 | "Topic :: Office/Business", 67 | "Topic :: Other/Nonlisted Topic", 68 | "Topic :: Software Development :: Build Tools", 69 | "Topic :: Software Development :: Libraries", 70 | "Topic :: Software Development :: Libraries :: Python Modules", 71 | "Topic :: Software Development :: Pre-processors", 72 | "Topic :: Software Development :: User Interfaces", 73 | "Topic :: System :: Installation/Setup", 74 | "Topic :: System :: Logging", 75 | "Topic :: System :: Monitoring", 76 | "Topic :: System :: Shells", 77 | "Topic :: Terminals", 78 | "Topic :: Utilities"] 79 | dependencies = [ 80 | "argopt>=0.3.5", 81 | 'importlib_resources; python_version < "3.9"', 82 | "tabulate", 83 | "tqdm>=4.44.0"] 84 | 85 | [project.optional-dependencies] 86 | dev = ["pytest>=6", "pytest-cov", "pytest-timeout", "pytest-xdist"] 87 | yaml = ["pyyaml"] 88 | tabulate = [] 89 | full = ["pyyaml"] 90 | 91 | [project.scripts] 92 | git-fame = "gitfame:main" 93 | 94 | [tool.flake8] 95 | max_line_length = 99 96 | extend_ignore = ["E261", "P103"] 97 | exclude = [".git", "__pycache__", "build", "dist", ".eggs", ".tox"] 98 | 99 | [tool.yapf] 100 | spaces_before_comment = [15, 20] 101 | arithmetic_precedence_indication = true 102 | allow_split_before_dict_value = false 103 | coalesce_brackets = true 104 | column_limit = 99 105 | each_dict_entry_on_separate_line = false 106 | space_between_ending_comma_and_closing_bracket = false 107 | split_before_named_assigns = false 108 | split_before_closing_bracket = false 109 | blank_line_before_nested_class_or_def = false 110 | 111 | [tool.isort] 112 | line_length = 99 113 | known_first_party = ["gitfame", "tests"] 114 | 115 | [tool.pytest.ini_options] 116 | timeout = 30 117 | log_level = "INFO" 118 | python_files = ["tests_*.py"] 119 | testpaths = ["tests"] 120 | addopts = "-v --tb=short -rxs --durations=0 --durations-min=0.1" 121 | filterwarnings = ["error", "ignore:co_lnotab:DeprecationWarning"] 122 | 123 | [tool.coverage.run] 124 | branch = true 125 | omit = ["tests/*"] 126 | relative_files = true 127 | [tool.coverage.report] 128 | show_missing = true 129 | -------------------------------------------------------------------------------- /tests/tests_gitfame.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from json import loads 3 | from os import path 4 | from shutil import rmtree 5 | from tempfile import mkdtemp 6 | from textwrap import dedent 7 | 8 | from pytest import mark, skip 9 | 10 | from gitfame import _gitfame, main 11 | 12 | # test data 13 | auth_stats = { 14 | 'Not Committed Yet': { 15 | 'files': {'gitfame/_gitfame.py', 'gitfame/_utils.py', 'Makefile', 'MANIFEST.in'}, 16 | 'loc': 75, 'ctimes': [], 'commits': 0}, 17 | 'Casper da Costa-Luis': { 18 | 'files': { 19 | 'gitfame/_utils.py', 'gitfame/__main__.py', 'setup.cfg', 'gitfame/_gitfame.py', 20 | 'gitfame/__init__.py', 'git-fame_completion.bash', 'Makefile', 'MANIFEST.in', 21 | '.gitignore', 'setup.py'}, 'loc': 538, 22 | 'ctimes': [ 23 | 1510942009, 1517426360, 1532103452, 1543323944, 1548030670, 1459558286, 1510942009, 24 | 1459559144, 1481150373, 1510942009, 1548030670, 1517178199, 1481150379, 1517426360, 25 | 1548030670, 1459625059, 1510942009, 1517426360, 1481150373, 1517337751, 1517426360, 26 | 1510942009, 1548030670, 1459099074, 1459598664, 1517337751, 1517176447, 1552697404, 27 | 1546630326, 1543326881, 1459558286, 1481150373, 1510930168, 1459598664, 1517596988], 28 | 'commits': 35}} 29 | stats_tot = {'files': 14, 'loc': 613, 'commits': 35} 30 | 31 | 32 | def test_tabulate(): 33 | """Test builtin tabulate""" 34 | assert (_gitfame.tabulate(auth_stats, stats_tot) == dedent("""\ 35 | Total commits: 35 36 | Total files: 14 37 | Total loc: 613 38 | | Author | loc | coms | fils | distribution | 39 | |:---------------------|------:|-------:|-------:|:----------------| 40 | | Casper da Costa-Luis | 538 | 35 | 10 | 87.8/ 100/71.4 | 41 | | Not Committed Yet | 75 | 0 | 4 | 12.2/ 0.0/28.6 |""")) 42 | 43 | assert "Not Committed Yet" not in _gitfame.tabulate(auth_stats, stats_tot, min_sort_val=76) 44 | 45 | 46 | def test_tabulate_cost(): 47 | """Test cost estimates""" 48 | assert (_gitfame.tabulate(auth_stats, stats_tot, cost={"hours", "months"}, 49 | width=256) == dedent("""\ 50 | Total commits: 35 51 | Total files: 14 52 | Total hours: 5.5 53 | Total loc: 613 54 | Total months: 1.9 55 | | Author | hrs | mths | loc | coms | fils \ 56 | | distribution | 57 | |:---------------------|------:|-------:|------:|-------:|-------:\ 58 | |:----------------| 59 | | Casper da Costa-Luis | 4 | 2 | 538 | 35 | 10 \ 60 | | 87.8/ 100/71.4 | 61 | | Not Committed Yet | 2 | 0 | 75 | 0 | 4 \ 62 | | 12.2/ 0.0/28.6 |""")) 63 | 64 | 65 | def test_tabulate_yaml(): 66 | """Test YAML tabulate""" 67 | res = [ 68 | dedent("""\ 69 | columns: 70 | - Author 71 | - loc 72 | - coms 73 | - fils 74 | - '%loc' 75 | - '%coms' 76 | - '%fils' 77 | data: 78 | - - Casper da Costa-Luis 79 | - 538 80 | - 35 81 | - 10 82 | - 87.8 83 | - 100.0 84 | - 71.4 85 | - - Not Committed Yet 86 | - 75 87 | - 0 88 | - 4 89 | - 12.2 90 | - 0.0 91 | - 28.6 92 | total: 93 | commits: 35 94 | files: 14 95 | loc: 613"""), 96 | dedent("""\ 97 | columns: [Author, loc, coms, fils, '%loc', '%coms', '%fils'] 98 | data: 99 | - [Casper da Costa-Luis, 538, 35, 10, 87.8, 100.0, 71.4] 100 | - [Not Committed Yet, 75, 0, 4, 12.2, 0.0, 28.6] 101 | total: {commits: 35, files: 14, loc: 613}""")] 102 | try: 103 | assert (_gitfame.tabulate(auth_stats, stats_tot, backend='yaml') in res) 104 | except ImportError as err: # lacking pyyaml<5 105 | raise skip(str(err)) 106 | 107 | 108 | def test_tabulate_json(): 109 | """Test JSON tabulate""" 110 | res = loads(_gitfame.tabulate(auth_stats, stats_tot, backend='json')) 111 | assert (res == loads( 112 | dedent("""\ 113 | {"total": {"files": 14, "loc": 613, "commits": 35}, 114 | "data": [["Casper da Costa-Luis", 538, 35, 10, 87.8, 100.0, 71.4], 115 | ["Not Committed Yet", 75, 0, 4, 12.2, 0.0, 28.6]], 116 | "columns": ["Author", "loc", "coms", "fils", 117 | "%loc", "%coms", "%fils"]}""").replace('\n', ' '))) 118 | 119 | 120 | def test_tabulate_csv(): 121 | """Test CSV tabulate""" 122 | csv = _gitfame.tabulate(auth_stats, stats_tot, backend='csv') 123 | tsv = _gitfame.tabulate(auth_stats, stats_tot, backend='tsv') 124 | assert (csv.replace(',', '\t') == tsv) 125 | 126 | 127 | def test_tabulate_tabulate(): 128 | """Test external tabulate""" 129 | try: 130 | assert (_gitfame.tabulate(auth_stats, stats_tot, backend='simple') == dedent("""\ 131 | Total commits: 35 132 | Total files: 14 133 | Total loc: 613 134 | Author loc coms fils distribution 135 | -------------------- ----- ------ ------ --------------- 136 | Casper da Costa-Luis 538 35 10 87.8/ 100/71.4 137 | Not Committed Yet 75 0 4 12.2/ 0.0/28.6""")) 138 | except ImportError as err: 139 | raise skip(str(err)) 140 | 141 | 142 | def test_tabulate_enum(): 143 | """Test --enum tabulate""" 144 | res = loads(_gitfame.tabulate(auth_stats, stats_tot, backend='json', row_nums=True)) 145 | assert res['columns'][0] == '#' 146 | assert [int(i[0]) for i in res['data']] == [1, 2] 147 | 148 | 149 | def test_tabulate_unknown(): 150 | """Test unknown tabulate format""" 151 | try: 152 | _gitfame.tabulate(auth_stats, stats_tot, backend='1337') 153 | except ValueError as e: 154 | if "unknown" not in str(e).lower(): 155 | raise 156 | else: 157 | raise ValueError("Should not support unknown tabulate format") 158 | 159 | 160 | @mark.parametrize( 161 | 'params', 162 | [['--sort', 'commits'], ['--no-regex'], ['--no-regex', '--incl', 'setup.py,README.rst'], 163 | ['--excl', r'.*\.py'], ['--loc', 'ins,del'], ['--cost', 'hour'], ['--cost', 'month'], 164 | ['--cost', 'month', '--excl', r'.*\.py'], ['-e'], ['-w'], ['-M'], ['-C'], ['-t'], 165 | ['--show=name,email'], ['--format=csv'], ['--format=svg']]) 166 | def test_options(params): 167 | """Test command line options""" 168 | main(['-s'] + params) 169 | 170 | 171 | def test_main(): 172 | """Test command line pipes""" 173 | import subprocess 174 | from os.path import dirname as dn 175 | 176 | res = subprocess.Popen((sys.executable, '-c', 177 | dedent('''\ 178 | import gitfame 179 | import sys 180 | sys.argv = ["", "--silent-progress", r"''' + dn(dn(__file__)) + '''"] 181 | gitfame.main() 182 | ''')), stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0] 183 | 184 | assert ('Total commits' in str(res)) 185 | 186 | 187 | def test_main_errors(capsys): 188 | """Test bad options""" 189 | main(['--silent-progress']) 190 | 191 | capsys.readouterr() # clear output 192 | try: 193 | main(['--bad', 'arg']) 194 | except SystemExit: 195 | out = capsys.readouterr() 196 | res = ' '.join(out.err.strip().split()[:2]) 197 | if res != "usage: gitfame": 198 | raise ValueError(out) 199 | else: 200 | raise ValueError("Expected --bad arg to fail") 201 | 202 | capsys.readouterr() # clear output 203 | try: 204 | main(['-s', '--sort', 'badSortArg']) 205 | except KeyError as e: 206 | if "badSortArg" not in str(e): 207 | raise ValueError("Expected `--sort=badSortArg` to fail") 208 | 209 | 210 | def test_manpath(): 211 | """Test --manpath""" 212 | tmp = mkdtemp() 213 | man = path.join(tmp, "git-fame.1") 214 | assert not path.exists(man) 215 | try: 216 | main(['--manpath', tmp]) 217 | except SystemExit: 218 | pass 219 | else: 220 | raise SystemExit("Expected system exit") 221 | assert path.exists(man) 222 | rmtree(tmp, True) 223 | 224 | 225 | def test_multiple_gitdirs(): 226 | """test multiple gitdirs""" 227 | main(['.', '.']) 228 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | git-fame 2 | ======== 3 | 4 | Pretty-print ``git`` repository collaborators sorted by contributions. 5 | 6 | |Py-Versions| |PyPI| |Conda-Forge| |Docker| |Snapcraft| 7 | 8 | |Build-Status| |Coverage-Status| |Branch-Coverage-Status| |Codacy-Grade| |Libraries-Rank| |PyPI-Downloads| 9 | 10 | |DOI-URI| |LICENCE| |OpenHub-Status| |Sponsor-Casper| 11 | 12 | .. code:: 13 | 14 | https://git-fame.cdcl.ml/gh/{owner}/{repo} 15 | 16 | |Contributions| 17 | 18 | .. code:: sh 19 | 20 | git fame --cost hour,month --loc ins 21 | 22 | .. code:: sh 23 | 24 | Processing: 100%|██████████████████████████| 1/1 [00:00<00:00, 2.16repo/s] 25 | Total commits: 1775 26 | Total ctimes: 2770 27 | Total files: 461 28 | Total hours: 449.7 29 | Total loc: 41659 30 | Total months: 151.0 31 | | Author | hrs | mths | loc | coms | fils | distribution | 32 | |:---------------------|------:|-------:|------:|-------:|-------:|:----------------| 33 | | Casper da Costa-Luis | 228 | 108 | 28572 | 1314 | 172 | 68.6/74.0/37.3 | 34 | | Stephen Larroque | 28 | 18 | 5243 | 203 | 25 | 12.6/11.4/ 5.4 | 35 | | pgajdos | 2 | 9 | 2606 | 2 | 18 | 6.3/ 0.1/ 3.9 | 36 | | Martin Zugnoni | 2 | 5 | 1656 | 3 | 3 | 4.0/ 0.2/ 0.7 | 37 | | Kyle Altendorf | 7 | 2 | 541 | 31 | 7 | 1.3/ 1.7/ 1.5 | 38 | | Hadrien Mary | 5 | 1 | 469 | 31 | 17 | 1.1/ 1.7/ 3.7 | 39 | | Richard Sheridan | 2 | 1 | 437 | 23 | 3 | 1.0/ 1.3/ 0.7 | 40 | | Guangshuo Chen | 3 | 1 | 321 | 18 | 7 | 0.8/ 1.0/ 1.5 | 41 | | Noam Yorav-Raphael | 4 | 1 | 229 | 11 | 6 | 0.5/ 0.6/ 1.3 | 42 | | github-actions[bot] | 2 | 1 | 186 | 1 | 51 | 0.4/ 0.1/11.1 | 43 | ... 44 | 45 | The ``distribution`` column is a percentage breakdown of ``loc/coms/fils``. 46 | (e.g. in the table above, Casper has written surviving code in 47 | ``172/461 = 37.3%`` of all files). 48 | 49 | ------------------------------------------ 50 | 51 | .. contents:: Table of contents 52 | :backlinks: top 53 | :local: 54 | 55 | 56 | Installation 57 | ------------ 58 | 59 | Latest PyPI stable release 60 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 61 | 62 | |PyPI| |PyPI-Downloads| |Libraries-Dependents| 63 | 64 | .. code:: sh 65 | 66 | pip install git-fame 67 | 68 | Latest development release on GitHub 69 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 70 | 71 | |GitHub-Status| |GitHub-Stars| |GitHub-Commits| |GitHub-Forks| |GitHub-Updated| 72 | 73 | Pull and install: 74 | 75 | .. code:: sh 76 | 77 | pip install "git+https://github.com/casperdcl/git-fame.git@main#egg=git-fame" 78 | 79 | Latest Conda release 80 | ~~~~~~~~~~~~~~~~~~~~ 81 | 82 | |Conda-Forge| 83 | 84 | .. code:: sh 85 | 86 | conda install -c conda-forge git-fame 87 | 88 | Latest Snapcraft release 89 | ~~~~~~~~~~~~~~~~~~~~~~~~ 90 | 91 | |Snapcraft| 92 | 93 | .. code:: sh 94 | 95 | snap install git-fame 96 | 97 | Latest Docker release 98 | ~~~~~~~~~~~~~~~~~~~~~ 99 | 100 | |Docker| 101 | 102 | .. code:: sh 103 | 104 | docker run --rm casperdcl/git-fame --help 105 | docker run --rm -v "/local/path/to/repository:/repo" -u "$(id -u)" casperdcl/git-fame 106 | 107 | Register alias with git 108 | ~~~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | On Windows, run: 111 | 112 | .. code:: sh 113 | 114 | git config --global alias.fame "!python -m gitfame" 115 | 116 | This is probably not necessary on UNIX systems. 117 | If ``git fame`` doesn't work after restarting the terminal on Linux & Mac OS, try (with single quotes): 118 | 119 | .. code:: sh 120 | 121 | git config --global alias.fame '!python -m gitfame' 122 | 123 | Tab completion 124 | ~~~~~~~~~~~~~~ 125 | 126 | Optionally, systems with ``bash-completion`` can install tab completion 127 | support. The 128 | `git-fame_completion.bash `_ 129 | file needs to be copied to an appropriate folder. 130 | 131 | On Ubuntu, the procedure would be: 132 | 133 | .. code:: sh 134 | 135 | $ # Ensure completion works for `git` itself 136 | $ sudo apt-get install bash-completion 137 | 138 | $ # Install `git fame` completions 139 | $ sudo wget \ 140 | https://raw.githubusercontent.com/casperdcl/git-fame/main/git-fame_completion.bash \ 141 | -O /etc/bash_completion.d/git-fame_completion.bash 142 | 143 | followed by a terminal restart. 144 | 145 | 146 | Changelog 147 | --------- 148 | 149 | The list of all changes is available on the Releases page: |GitHub-Status| 150 | 151 | 152 | Usage 153 | ----- 154 | 155 | .. code:: sh 156 | 157 | git fame # If alias registered with git (see above) 158 | git-fame # Alternative execution as python console script 159 | python -m gitfame # Alternative execution as python module 160 | git-fame -h # Print help 161 | 162 | For example, to print statistics regarding all source files in a C++/CUDA 163 | repository (``*.c/h/t(pp), *.cu(h)``), carefully handling whitespace and line 164 | copies: 165 | 166 | .. code:: sh 167 | 168 | git fame --incl '\.[cht][puh]{0,2}$' -twMC 169 | 170 | It is also possible to run from within a python shell or script. 171 | 172 | .. code:: python 173 | 174 | >>> import gitfame 175 | >>> gitfame.main(['--sort=commits', '-wt', '/path/to/my/repo']) 176 | 177 | Finally, there is a live server for public GitHub repositories at `git-fame.cdcl.ml/gh/{owner}/{repo} `_. 178 | 179 | The ``rendered by git-fame.cdcl.ml`` watermark is removed for sponsors of `casperdcl `_: |Sponsor-Casper| 180 | 181 | 182 | Documentation 183 | ------------- 184 | 185 | .. code:: 186 | 187 | Usage: 188 | git-fame [--help | options] [...] 189 | 190 | Arguments: 191 | Git directory [default: ./]. 192 | May be specified multiple times to aggregate across 193 | multiple repositories. 194 | 195 | Options: 196 | -h, --help Print this help and exit. 197 | -v, --version Print module version and exit. 198 | --branch= Branch or tag [default: HEAD] up to which to check. 199 | --sort= [default: loc]|commits|files|hours|months. 200 | --min= Minimum value (of `--sort` key) to show [default: 0:int]. 201 | --loc= surv(iving)|ins(ertions)|del(etions) 202 | What `loc` represents. Use 'ins,del' to count both. 203 | defaults to 'surviving' unless `--cost` is specified. 204 | --excl= Excluded files (default: None). 205 | In no-regex mode, may be a comma-separated list. 206 | Escape (\,) for a literal comma (may require \\, in shell). 207 | --incl= Included files [default: .*]. See `--excl` for format. 208 | --since= Date from which to check. Can be absolute (eg: 1970-01-31) 209 | or relative to now (eg: 3.weeks). 210 | --until= Date to which to check. See `--since` for format. 211 | --cost= Include time cost in person-months (COCOMO) or 212 | person-hours (based on commit times). 213 | Methods: month(s)|cocomo|hour(s)|commit(s). 214 | May be multiple comma-separated values. 215 | Alters `--loc` default to imply 'ins' (COCOMO) or 216 | 'ins,del' (hours). 217 | -R, --recurse Recursively find repositories & submodules within . 218 | -n, --no-regex Assume are comma-separated exact matches 219 | rather than regular expressions [default: False]. 220 | NB: if regex is enabled ',' is equivalent to '|'. 221 | -s, --silent-progress Suppress `tqdm` [default: False]. 222 | --warn-binary Don't silently skip files which appear to be binary data 223 | [default: False]. 224 | --show= Author information to show [default: name]|email. 225 | Use 'name,email' to show both. 226 | -e, --show-email Shortcut for `--show=email`. 227 | --enum Show row numbers [default: False]. 228 | -t, --bytype Show stats per file extension [default: False]. 229 | -w, --ignore-whitespace Ignore whitespace when comparing the parent's 230 | version and the child's to find where the lines 231 | came from [default: False]. 232 | -M Detect intra-file line moves and copies [default: False]. 233 | -C Detect inter-file line moves and copies [default: False]. 234 | --ignore-rev= Ignore changes made by the given revision 235 | (requires `--loc=surviving`). 236 | --ignore-revs-file= Ignore revisions listed in the given file 237 | (requires `--loc=surviving`). 238 | --format= Table format 239 | svg|[default: pipe]|md|markdown|yaml|yml|json|csv|tsv|tabulate. 240 | May require `git-fame[]`, e.g. `pip install git-fame[yaml]`. 241 | Any `tabulate.tabulate_formats` is also accepted. 242 | --manpath= Directory in which to install git-fame man pages. 243 | --log= FATAL|CRITICAL|ERROR|WARN(ING)|[default: INFO]|DEBUG|NOTSET. 244 | 245 | 246 | If multiple user names and/or emails correspond to the same user, aggregate 247 | ``git-fame`` statistics and maintain a ``git`` repository properly by adding a 248 | `.mailmap file `_. 249 | 250 | FAQs 251 | ~~~~ 252 | 253 | Options such as ``-w``, ``-M``, and ``-C`` can increase accuracy, but take 254 | longer to compute. 255 | 256 | Note that specifying ``--sort=hours`` or ``--sort=months`` requires ``--cost`` 257 | to be specified appropriately. 258 | 259 | Note that ``--cost=months`` (``--cost=COCOMO``) approximates 260 | `person-months `_ and should be used with 261 | ``--loc=ins``. 262 | 263 | Meanwhile, ``--cost=hours`` (``--cost=commits``) approximates 264 | `person-hours `_. 265 | 266 | Extra care should be taken when using ``ins`` and/or ``del`` for ``--loc`` 267 | since all historical files (including those no longer surviving) are counted. 268 | In such cases, ``--excl`` may need to be significantly extended. 269 | On the plus side, it is faster to compute ``ins`` and ``del`` compared to 270 | ``surv``. 271 | 272 | 273 | Examples 274 | -------- 275 | 276 | Badges 277 | ~~~~~~ 278 | 279 | An SVG image for inclusion in README files and websites: 280 | 281 | .. code:: sh 282 | 283 | git fame -wMC --format svg --min 1 > docs/authors.svg 284 | 285 | Which can also be dynamically created for public GitHub repositories: 286 | 287 | .. code:: md 288 | 289 | ![markdown-image](https://git-fame.cdcl.ml/gh/{owner}/{repo}?min=1) 290 | 291 | CODEOWNERS 292 | ~~~~~~~~~~ 293 | 294 | Generating 295 | `CODEOWNERS `_: 296 | 297 | .. code:: sh 298 | 299 | # bash syntax function for current directory git repository 300 | owners(){ 301 | for f in $(git ls-files); do 302 | # filename 303 | echo -n "$f " 304 | # author emails if loc distribution >= 30% 305 | git fame -esnwMC --incl "$f" | tr '/' '|' \ 306 | | awk -F '|' '(NR>6 && $6>=30) {print $2}' \ 307 | | xargs echo 308 | done 309 | } 310 | 311 | # print to screen and file 312 | owners | tee .github/CODEOWNERS 313 | 314 | # same but with `tqdm` progress for large repos 315 | owners \ 316 | | tqdm --total $(git ls-files | wc -l) \ 317 | --unit file --desc "Generating CODEOWNERS" \ 318 | > .github/CODEOWNERS 319 | 320 | Zenodo config 321 | ~~~~~~~~~~~~~ 322 | 323 | Generating `.zenodo.json `_: 324 | 325 | .. code:: sh 326 | 327 | git fame -wMC --format json \ 328 | | jq -c '{creators: [.data[] | {name: .[0]}]}' \ 329 | | sed -r -e 's/(\{"name")/\n \1/g' -e 's/:/: /g' \ 330 | > .zenodo.json 331 | 332 | 333 | Contributions 334 | ------------- 335 | 336 | |GitHub-Commits| |GitHub-Issues| |GitHub-PRs| |OpenHub-Status| 337 | 338 | All source code is hosted on `GitHub `_. 339 | Contributions are welcome. 340 | 341 | |Contributions| 342 | 343 | The ``rendered by git-fame.cdcl.ml`` watermark is removed for sponsors of `casperdcl `_: |Sponsor-Casper| 344 | 345 | LICENCE 346 | ------- 347 | 348 | Open Source (OSI approved): |LICENCE| 349 | 350 | Citation information: |DOI-URI| 351 | 352 | |README-Hits| 353 | 354 | .. |Build-Status| image:: https://img.shields.io/github/actions/workflow/status/casperdcl/git-fame/test.yml?branch=main&label=git-fame&logo=GitHub 355 | :target: https://github.com/casperdcl/git-fame/actions/workflows/test.yml 356 | .. |Coverage-Status| image:: https://img.shields.io/coveralls/github/casperdcl/git-fame/main?logo=coveralls 357 | :target: https://coveralls.io/github/casperdcl/git-fame 358 | .. |Branch-Coverage-Status| image:: https://codecov.io/gh/casperdcl/git-fame/branch/main/graph/badge.svg 359 | :target: https://codecov.io/gh/casperdcl/git-fame 360 | .. |Codacy-Grade| image:: https://api.codacy.com/project/badge/Grade/bde789ee0e57491eb2bb8609bd4190c3 361 | :target: https://www.codacy.com/app/casper-dcl/git-fame/dashboard 362 | .. |GitHub-Status| image:: https://img.shields.io/github/tag/casperdcl/git-fame.svg?maxAge=86400&logo=github 363 | :target: https://github.com/casperdcl/git-fame/releases 364 | .. |GitHub-Forks| image:: https://img.shields.io/github/forks/casperdcl/git-fame.svg?logo=github 365 | :target: https://github.com/casperdcl/git-fame/network 366 | .. |GitHub-Stars| image:: https://img.shields.io/github/stars/casperdcl/git-fame.svg?logo=github 367 | :target: https://github.com/casperdcl/git-fame/stargazers 368 | .. |GitHub-Commits| image:: https://img.shields.io/github/commit-activity/y/casperdcl/git-fame?label=commits&logo=git 369 | :target: https://github.com/casperdcl/git-fame/graphs/commit-activity 370 | .. |GitHub-Issues| image:: https://img.shields.io/github/issues-closed/casperdcl/git-fame.svg?logo=github 371 | :target: https://github.com/casperdcl/git-fame/issues 372 | .. |GitHub-PRs| image:: https://img.shields.io/github/issues-pr-closed/casperdcl/git-fame.svg?logo=github 373 | :target: https://github.com/casperdcl/git-fame/pulls 374 | .. |Contributions| image:: https://git-fame.cdcl.ml/gh/casperdcl/git-fame 375 | :target: https://git-fame.cdcl.ml/gh/casperdcl/git-fame 376 | .. |GitHub-Updated| image:: https://img.shields.io/github/last-commit/casperdcl/git-fame?label=pushed&logo=github 377 | :target: https://github.com/casperdcl/git-fame/pulse 378 | .. |Sponsor-Casper| image:: https://img.shields.io/badge/sponsor-FOSS-dc10ff.svg?logo=Contactless%20Payment 379 | :target: https://cdcl.ml/sponsor 380 | .. |PyPI| image:: https://img.shields.io/pypi/v/git-fame.svg?logo=PyPI&logoColor=white 381 | :target: https://pypi.org/project/git-fame 382 | .. |PyPI-Downloads| image:: https://img.shields.io/pypi/dm/git-fame.svg?label=pypi%20downloads&logo=DocuSign 383 | :target: https://pypi.org/project/git-fame 384 | .. |Py-Versions| image:: https://img.shields.io/pypi/pyversions/git-fame.svg?logo=python&logoColor=white 385 | :target: https://pypi.org/project/git-fame 386 | .. |Conda-Forge| image:: https://img.shields.io/conda/v/conda-forge/git-fame.svg?label=conda-forge&logo=conda-forge 387 | :target: https://anaconda.org/conda-forge/git-fame 388 | .. |Snapcraft| image:: https://img.shields.io/badge/snap-install-blue.svg?logo=snapcraft&logoColor=white 389 | :target: https://snapcraft.io/git-fame 390 | .. |Docker| image:: https://img.shields.io/badge/docker-pull-blue.svg?logo=docker&logoColor=white 391 | :target: https://hub.docker.com/r/casperdcl/git-fame 392 | .. |Libraries-Rank| image:: https://img.shields.io/librariesio/sourcerank/pypi/git-fame.svg?color=green&logo=koding 393 | :target: https://libraries.io/pypi/git-fame 394 | .. |Libraries-Dependents| image:: https://img.shields.io/librariesio/dependent-repos/pypi/git-fame.svg?logo=koding 395 | :target: https://github.com/casperdcl/git-fame/network/dependents 396 | .. |OpenHub-Status| image:: https://www.openhub.net/p/git-fame/widgets/project_thin_badge?format=gif 397 | :target: https://www.openhub.net/p/git-fame?ref=Thin+badge 398 | .. |LICENCE| image:: https://img.shields.io/pypi/l/git-fame.svg?color=purple&logo=SPDX 399 | :target: https://raw.githubusercontent.com/casperdcl/git-fame/main/LICENCE 400 | .. |DOI-URI| image:: https://img.shields.io/badge/DOI-10.5281/zenodo.2544975-blue.svg?color=purple&logo=ORCID 401 | :target: https://doi.org/10.5281/zenodo.2544975 402 | .. |README-Hits| image:: https://cgi.cdcl.ml/hits?q=git-fame&style=social&r=https://github.com/casperdcl/git-fame 403 | :target: https://cgi.cdcl.ml/hits?q=git-fame&a=plot&r=https://github.com/casperdcl/git-fame&style=social 404 | -------------------------------------------------------------------------------- /gitfame/_gitfame.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | r"""Usage: 3 | gitfame [--help | options] [...] 4 | 5 | Arguments: 6 | Git directory [default: ./]. 7 | May be specified multiple times to aggregate across 8 | multiple repositories. 9 | 10 | Options: 11 | -h, --help Print this help and exit. 12 | -v, --version Print module version and exit. 13 | --branch= Branch or tag [default: HEAD] up to which to check. 14 | --sort= [default: loc]|commits|files|hours|months. 15 | --min= Minimum value (of `--sort` key) to show [default: 0:int]. 16 | --loc= surv(iving)|ins(ertions)|del(etions) 17 | What `loc` represents. Use 'ins,del' to count both. 18 | defaults to 'surviving' unless `--cost` is specified. 19 | --excl= Excluded files (default: None). 20 | In no-regex mode, may be a comma-separated list. 21 | Escape (\,) for a literal comma (may require \\, in shell). 22 | --incl= Included files [default: .*]. See `--excl` for format. 23 | --since= Date from which to check. Can be absolute (eg: 1970-01-31) 24 | or relative to now (eg: 3.weeks). 25 | --until= Date to which to check. See `--since` for format. 26 | --cost= Include time cost in person-months (COCOMO) or 27 | person-hours (based on commit times). 28 | Methods: month(s)|cocomo|hour(s)|commit(s). 29 | May be multiple comma-separated values. 30 | Alters `--loc` default to imply 'ins' (COCOMO) or 31 | 'ins,del' (hours). 32 | -R, --recurse Recursively find repositories & submodules within . 33 | -n, --no-regex Assume are comma-separated exact matches 34 | rather than regular expressions [default: False]. 35 | NB: if regex is enabled ',' is equivalent to '|'. 36 | -s, --silent-progress Suppress `tqdm` [default: False]. 37 | --warn-binary Don't silently skip files which appear to be binary data 38 | [default: False]. 39 | --show= Author information to show [default: name]|email. 40 | Use 'name,email' to show both. 41 | -e, --show-email Shortcut for `--show=email`. 42 | --enum Show row numbers [default: False]. 43 | -t, --bytype Show stats per file extension [default: False]. 44 | -w, --ignore-whitespace Ignore whitespace when comparing the parent's 45 | version and the child's to find where the lines 46 | came from [default: False]. 47 | -M Detect intra-file line moves and copies [default: False]. 48 | -C Detect inter-file line moves and copies [default: False]. 49 | --ignore-rev= Ignore changes made by the given revision 50 | (requires `--loc=surviving`). 51 | --ignore-revs-file= Ignore revisions listed in the given file 52 | (requires `--loc=surviving`). 53 | --format= Table format 54 | svg|[default: pipe]|md|markdown|yaml|yml|json|csv|tsv|tabulate. 55 | May require `git-fame[]`, e.g. `pip install git-fame[yaml]`. 56 | Any `tabulate.tabulate_formats` is also accepted. 57 | --manpath= Directory in which to install git-fame man pages. 58 | --log= FATAL|CRITICAL|ERROR|WARN(ING)|[default: INFO]|DEBUG|NOTSET. 59 | """ 60 | import logging 61 | import os 62 | import re 63 | import subprocess 64 | from collections import defaultdict 65 | from functools import partial 66 | from os import path 67 | 68 | from ._utils import (TERM_WIDTH, Str, TqdmStream, check_output, fext, int_cast_or_len, merge_stats, 69 | print_unicode, tqdm) 70 | 71 | # version detector. Precedence: installed dist, git, 'UNKNOWN' 72 | try: 73 | from ._dist_ver import __version__ 74 | except ImportError: 75 | try: 76 | from setuptools_scm import get_version 77 | __version__ = get_version(root='..', relative_to=__file__) 78 | except (ImportError, LookupError): 79 | __version__ = "UNKNOWN" 80 | __author__ = "Casper da Costa-Luis " 81 | __date__ = "2016-2025" 82 | __licence__ = "[MPLv2.0](https://mozilla.org/MPL/2.0/)" 83 | __all__ = ["main"] 84 | __copyright__ = ' '.join(("Copyright (c)", __date__, __author__, __licence__)) 85 | __license__ = __licence__ # weird foreign language 86 | log = logging.getLogger(__name__) 87 | 88 | # processing `blame --line-porcelain` 89 | RE_AUTHS_BLAME = re.compile( 90 | r'^\w+ \d+ \d+ (\d+)\nauthor (.+?)\nauthor-mail <(.*?)>$.*?\ncommitter-time (\d+)', 91 | flags=re.M | re.DOTALL) 92 | RE_NCOM_AUTH_EM = re.compile(r'^\s*(\d+)\s+(.*?)\s+<(.*)>\s*$', flags=re.M) 93 | RE_BLAME_BOUNDS = re.compile( 94 | r'^\w+\s+\d+\s+\d+(\s+\d+)?\s*$[^\t]*?^boundary\s*$[^\t]*?^\t.*?$\r?\n', 95 | flags=re.M | re.DOTALL) 96 | # processing `log --format="aN%aN aE%aE ct%ct" --numstat` 97 | RE_AUTHS_LOG = re.compile(r"^aN(.+?) aE(.*?) ct(\d+)\n\n", flags=re.M) 98 | RE_STAT_BINARY = re.compile(r"^\s*?-\s*-.*?\n", flags=re.M) 99 | RE_RENAME = re.compile(r"\{.+? => (.+?)\}") 100 | # finds all non-escaped commas 101 | # NB: does not support escaping of escaped character 102 | RE_CSPILT = re.compile(r'(?= min_sort_val] 160 | tab.sort(key=lambda i: i[COL_NAMES.index(sort)], reverse=True) 161 | if row_nums: 162 | tab = [[str(i)] + j for i, j in enumerate(tab, 1)] 163 | COL_NAMES.insert(0, '#') 164 | 165 | totals = 'Total ' + '\nTotal '.join("%s: %s" % i for i in sorted(stats_tot.items())) + '\n' 166 | 167 | if (backend := backend.lower()) in ("tabulate", "md", "markdown"): 168 | backend = "pipe" 169 | svg = backend == 'svg' 170 | if svg: 171 | backend = 'rounded_outline' 172 | 173 | if backend in ('yaml', 'yml', 'json', 'csv', 'tsv'): 174 | tab = [i[:-1] + [float(pc.strip()) for pc in i[-1].split('/')] for i in tab] 175 | tab = { 176 | "total": stats_tot, "data": tab, 177 | "columns": COL_NAMES[:-1] + ['%' + i for i in COL_NAMES[-4:-1]]} 178 | if backend in ('yaml', 'yml'): 179 | log.debug("backend:yaml") 180 | from yaml import safe_dump as tabber 181 | return tabber(tab).rstrip() 182 | elif backend == 'json': 183 | log.debug("backend:json") 184 | from json import dumps as tabber 185 | return tabber(tab, ensure_ascii=False) 186 | elif backend in ('csv', 'tsv'): 187 | log.debug("backend:csv") 188 | from csv import writer as tabber 189 | from io import StringIO 190 | 191 | res = StringIO() 192 | t = tabber(res, delimiter=',' if backend == 'csv' else '\t') 193 | t.writerow(tab['columns']) 194 | t.writerows(tab['data']) 195 | t.writerow('') 196 | t.writerow(list(tab['total'].keys())) 197 | t.writerow(list(tab['total'].values())) 198 | return res.getvalue().rstrip() 199 | else: # pragma: nocover 200 | raise RuntimeError("Should be unreachable") 201 | else: 202 | import tabulate as tabber 203 | 204 | if backend not in tabber.tabulate_formats: 205 | raise ValueError(f"Unknown backend:{backend}") 206 | log.debug("backend:tabulate:%s", backend) 207 | COL_LENS = [max(len(Str(i[j])) for i in [COL_NAMES] + tab) for j in range(len(COL_NAMES))] 208 | COL_LENS[0] = min(width - sum(COL_LENS[1:]) - len(COL_LENS) * 3 - 4, COL_LENS[0]) 209 | tab = [[i[0][:COL_LENS[0]]] + i[1:] for i in tab] 210 | table = tabber.tabulate(tab, COL_NAMES, tablefmt=backend, floatfmt='.0f') 211 | if svg: 212 | rows = table.split('\n') 213 | return ('' 215 | '' 217 | '' + 219 | ''.join(f'{row}' 220 | for row in rows) + '') 221 | return totals + table 222 | 223 | # from ._utils import tighten 224 | # return totals + tighten(tabber(...), max_width=TERM_WIDTH) 225 | 226 | 227 | def _get_auth_stats(gitdir, branch="HEAD", since=None, include_files=None, exclude_files=None, 228 | silent_progress=False, ignore_whitespace=False, M=False, C=False, 229 | warn_binary=False, bytype=False, show=None, prefix_gitdir=False, churn=None, 230 | ignore_rev="", ignore_revs_file=None, until=None): 231 | """Returns dict: {"": {"loc": int, "files": {}, "commits": int, "ctimes": [int]}}""" 232 | until = ["--until", until] if until else [] 233 | since = ["--since", since] if since else [] 234 | show = show or SHOW_NAME 235 | git_cmd = ["git", "-C", gitdir] 236 | log.debug("base command:%s", git_cmd) 237 | file_list = check_output(git_cmd + ["ls-files", "--with-tree", branch]).strip().split('\n') 238 | text_file_list = check_output(git_cmd + ["grep", "-I", "--name-only", ".", branch]).strip() 239 | text_file_list = set( 240 | re.sub(f"^{re.escape(branch)}:", "", text_file_list, flags=re.M).split('\n')) 241 | if not hasattr(include_files, 'search'): 242 | file_list = [ 243 | i for i in file_list if (not include_files or (i in include_files)) 244 | if i not in exclude_files] 245 | else: 246 | file_list = [ 247 | i for i in file_list if include_files.search(i) 248 | if not (exclude_files and exclude_files.search(i))] 249 | for fname in set(file_list) - text_file_list: 250 | getattr(log, "warn" if warn_binary else "debug")("binary:%s", fname.strip()) 251 | file_list = [f for f in file_list if f in text_file_list] # preserve order 252 | log.log(logging.NOTSET, "files:%s", file_list) 253 | churn = churn or set() 254 | 255 | if churn & CHURN_SLOC: 256 | base_cmd = git_cmd + ["blame", "--line-porcelain"] + since + until 257 | if ignore_rev: 258 | base_cmd.extend(["--ignore-rev", ignore_rev]) 259 | if ignore_revs_file: 260 | base_cmd.extend(["--ignore-revs-file", ignore_revs_file]) 261 | else: 262 | base_cmd = git_cmd + ["log", "--format=aN%aN aE%aE ct%ct", "--numstat"] + since + until 263 | 264 | if ignore_whitespace: 265 | base_cmd.append("-w") 266 | if M: 267 | base_cmd.append("-M") 268 | if C: 269 | base_cmd.extend(["-C", "-C"]) # twice to include file creation 270 | 271 | auth_stats = {} 272 | 273 | def stats_append(fname, auth, loc, tstamp): 274 | tstamp = int(tstamp) 275 | if (auth := str(auth)) not in auth_stats: 276 | auth_stats[auth] = defaultdict(int, files=set(), ctimes=[]) 277 | auth_stats[auth]["loc"] += loc 278 | auth_stats[auth]["files"].add(fname) 279 | auth_stats[auth]["ctimes"].append(tstamp) 280 | 281 | if bytype: 282 | fext_key = f".{fext(fname) or '_None_ext'}" 283 | auth_stats[auth][fext_key] += loc 284 | 285 | if churn & CHURN_SLOC: 286 | for fname in tqdm(file_list, desc=gitdir if prefix_gitdir else "Processing", 287 | disable=silent_progress, unit="file"): 288 | if prefix_gitdir: 289 | fname = path.join(gitdir, fname) 290 | try: 291 | blame_out = check_output(base_cmd + [branch, fname], stderr=subprocess.STDOUT) 292 | except Exception as err: 293 | getattr(log, "warn" if warn_binary else "debug")(fname + ':' + str(err)) 294 | continue 295 | log.log(logging.NOTSET, blame_out) 296 | 297 | if since: 298 | # Strip boundary messages, 299 | # preventing user with nearest commit to boundary owning the LOC 300 | blame_out = RE_BLAME_BOUNDS.sub('', blame_out) 301 | 302 | if until: 303 | # Strip boundary messages, 304 | # preventing user with nearest commit to boundary owning the LOC 305 | blame_out = RE_BLAME_BOUNDS.sub('', blame_out) 306 | 307 | for loc, name, email, tstamp in RE_AUTHS_BLAME.findall(blame_out): # for each chunk 308 | loc = int(loc) 309 | auth = f'{name} <{email}>' 310 | stats_append(fname, auth, loc, tstamp) 311 | 312 | else: 313 | with tqdm(total=1, desc=gitdir if prefix_gitdir else "Processing", disable=silent_progress, 314 | unit="repo") as t: 315 | blame_out = check_output(base_cmd + [branch], stderr=subprocess.STDOUT) 316 | t.update() 317 | log.log(logging.NOTSET, blame_out) 318 | 319 | # Strip binary files 320 | for fname in set(RE_STAT_BINARY.findall(blame_out)): 321 | getattr(log, "warn" if warn_binary else "debug")("binary:%s", fname.strip()) 322 | blame_out = RE_STAT_BINARY.sub('', blame_out) 323 | 324 | blame_out = RE_AUTHS_LOG.split(blame_out) 325 | blame_out = zip(blame_out[1::4], blame_out[2::4], blame_out[3::4], blame_out[4::4]) 326 | for name, email, tstamp, fnames in blame_out: 327 | auth = f'{name} <{email}>' 328 | fnames = fnames.split('\naN', 1)[0] 329 | for i in fnames.strip().split('\n'): 330 | try: 331 | inss, dels, fname = i.split('\t') 332 | except ValueError: 333 | log.warning(i) 334 | else: 335 | if (fname := RE_RENAME.sub(r'\\2', fname)) in file_list: 336 | loc = int(inss) if churn & CHURN_INS and inss else 0 337 | loc += int(dels) if churn & CHURN_DEL and dels else 0 338 | stats_append(fname, auth, loc, tstamp) 339 | 340 | # quickly count commits (even if no surviving loc) 341 | log.log(logging.NOTSET, "authors:%s", list(auth_stats.keys())) 342 | auth_commits = check_output(git_cmd + ["shortlog", "-s", "-e", branch] + since + until) 343 | log.debug(RE_NCOM_AUTH_EM.findall(auth_commits.strip())) 344 | auth2em = {} 345 | auth2name = {} 346 | for (ncom, name, em) in RE_NCOM_AUTH_EM.findall(auth_commits.strip()): 347 | auth = f'{name} <{em}>' 348 | auth2em[auth] = em 349 | auth2name[auth] = name 350 | if auth not in auth_stats: 351 | auth_stats[auth] = defaultdict(int, files=set(), ctimes=[]) 352 | auth_stats[auth]["commits"] += int(ncom) 353 | if not (show & SHOW_NAME and show & SHOW_EMAIL): # replace author with either email or name 354 | auth2new = auth2em if (show & SHOW_EMAIL) else auth2name 355 | log.debug(auth2new) 356 | old = auth_stats 357 | auth_stats = {} 358 | 359 | for auth, stats in old.items(): 360 | i = auth_stats.setdefault(auth2new[auth], defaultdict(int, files=set(), ctimes=[])) 361 | i["files"].update(stats["files"]) 362 | for k, v in stats.items(): 363 | if k != 'files': 364 | i[k] += v 365 | del old 366 | 367 | return auth_stats 368 | 369 | 370 | def run(args): 371 | """args : Namespace (`argopt.DictAttrWrap` or from `argparse`)""" 372 | log.debug("parsing args") 373 | 374 | if args.sort not in "loc commits files hours months".split(): 375 | log.warning("--sort argument (%s) unrecognised\n%s", args.sort, __doc__) 376 | raise KeyError(args.sort) 377 | 378 | args.show = set(args.show.lower().split(',')) 379 | if args.show_email: 380 | args.show = SHOW_EMAIL 381 | 382 | if not args.excl: 383 | args.excl = "" 384 | 385 | if isinstance(args.gitdir, str): 386 | args.gitdir = [args.gitdir] 387 | # strip `/`, `.git` 388 | gitdirs = [i.rstrip(os.sep) for i in args.gitdir] 389 | gitdirs = [ 390 | path.join(*path.split(i)[:-1]) if path.split(i)[-1] == '.git' else i for i in args.gitdir] 391 | # remove duplicates 392 | for i, d in reversed(list(enumerate(gitdirs))): 393 | if d in gitdirs[:i]: 394 | gitdirs.pop(i) 395 | # recurse 396 | if args.recurse: 397 | nDirs = len(gitdirs) 398 | i = 0 399 | while i < nDirs: 400 | if path.isdir(gitdirs[i]): 401 | for root, dirs, fns in tqdm(os.walk(gitdirs[i]), desc="Recursing", unit="dir", 402 | disable=args.silent_progress, leave=False): 403 | if '.git' in fns + dirs: 404 | if root not in gitdirs: 405 | gitdirs.append(root) 406 | if '.git' in dirs: 407 | dirs.remove('.git') 408 | i += 1 409 | 410 | exclude_files = None 411 | include_files = None 412 | if args.no_regex: 413 | exclude_files = set(RE_CSPILT.split(args.excl)) 414 | include_files = set() 415 | if args.incl == ".*": 416 | args.incl = "" 417 | else: 418 | include_files.update(RE_CSPILT.split(args.incl)) 419 | else: 420 | # cannot use findall in case of grouping: 421 | # for i in include_files: 422 | # for i in [include_files]: 423 | # for j in range(1, len(i)): 424 | # if i[j] == '(' and i[j - 1] != '\\': 425 | # raise ValueError('Parenthesis must be escaped' 426 | # ' in include-files:\n\t' + i) 427 | exclude_files = re.compile(args.excl) if args.excl else None 428 | include_files = re.compile(args.incl) 429 | # include_files = re.compile(args.incl, flags=re.M) 430 | 431 | cost = set(args.cost.lower().split(',')) if args.cost else set() 432 | churn = set(args.loc.lower().split(',')) if args.loc else set() 433 | if not churn: 434 | if cost & COST_HOURS: 435 | churn = CHURN_INS | CHURN_DEL 436 | elif cost & COST_MONTHS: 437 | churn = CHURN_INS 438 | else: 439 | churn = CHURN_SLOC 440 | 441 | if churn & (CHURN_INS | CHURN_DEL) and args.excl: 442 | log.warning("--loc=ins,del includes historical files" 443 | " which may need to be added to --excl") 444 | 445 | auth_stats = {} 446 | statter = partial(_get_auth_stats, branch=args.branch, since=args.since, until=args.until, 447 | include_files=include_files, exclude_files=exclude_files, 448 | silent_progress=args.silent_progress, 449 | ignore_whitespace=args.ignore_whitespace, M=args.M, C=args.C, 450 | warn_binary=args.warn_binary, bytype=args.bytype, show=args.show, 451 | prefix_gitdir=len(gitdirs) > 1, churn=churn, ignore_rev=args.ignore_rev, 452 | ignore_revs_file=args.ignore_revs_file) 453 | 454 | # concurrent multi-repo processing 455 | if len(gitdirs) > 1: 456 | try: 457 | from concurrent.futures import ThreadPoolExecutor # NOQA, yapf: disable 458 | 459 | from tqdm.contrib.concurrent import thread_map 460 | mapper = partial(thread_map, desc="Repos", unit="repo", miniters=1, 461 | disable=args.silent_progress or len(gitdirs) <= 1) 462 | except ImportError: 463 | mapper = map 464 | else: 465 | mapper = map 466 | 467 | for res in mapper(statter, gitdirs): 468 | for auth, stats in res.items(): 469 | if auth in auth_stats: 470 | merge_stats(auth_stats[auth], stats) 471 | else: 472 | auth_stats[auth] = stats 473 | 474 | stats_tot = {k: 0 for stats in auth_stats.values() for k in stats} 475 | log.debug(stats_tot) 476 | for k in stats_tot: 477 | stats_tot[k] = sum(int_cast_or_len(stats.get(k, 0)) for stats in auth_stats.values()) 478 | log.debug(stats_tot) 479 | 480 | # TODO: 481 | # extns = set() 482 | # if args.bytype: 483 | # for stats in auth_stats.values(): 484 | # extns.update([fext(i) for i in stats["files"]]) 485 | # log.debug(extns) 486 | 487 | print_unicode( 488 | tabulate(auth_stats, stats_tot, args.sort, args.bytype, args.format, cost, args.enum, 489 | args.min)) 490 | 491 | 492 | def get_main_parser(): 493 | from argopt import argopt 494 | return argopt(__doc__ + '\n' + __copyright__, version=__version__) 495 | 496 | 497 | def main(args=None): 498 | """args : list [default: sys.argv[1:]]""" 499 | parser = get_main_parser() 500 | args = parser.parse_args(args=args) 501 | logging.basicConfig(level=getattr(logging, args.log, logging.INFO), stream=TqdmStream, 502 | format="%(levelname)s:gitfame.%(funcName)s:%(lineno)d:%(message)s") 503 | 504 | log.debug(args) 505 | if args.manpath is not None: 506 | import sys 507 | from pathlib import Path 508 | 509 | try: # py<3.9 510 | import importlib_resources as resources 511 | except ImportError: 512 | from importlib import resources 513 | fi = resources.files('gitfame') / 'git-fame.1' 514 | fo = Path(args.manpath) / 'git-fame.1' 515 | fo.write_bytes(fi.read_bytes()) 516 | log.info("written:%s", fo) 517 | sys.exit(0) 518 | 519 | run(args) 520 | 521 | 522 | if __name__ == "__main__": # pragma: no cover 523 | main() 524 | --------------------------------------------------------------------------------