import datetime import getopt import glob import os import pickle import platform import re import shutil import subprocess import sys import time import zlib from collections import defaultdict from fpdf import FPDF from fpdf.enums import XPos, YPos if sys.version_info < (3, 6): print("Python 3.6 or higher is required for gitstats", file=sys.stderr) sys.exit(1) from multiprocessing import Pool os.environ['LC_ALL'] = 'C' GNUPLOT_COMMON = 'set terminal png transparent size 640,240\nset size 1.0,1.0\n' ON_LINUX = (platform.system() == 'Linux') WEEKDAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun') exectime_internal = 0.0 exectime_external = 0.0 time_start = time.time() # By default, gnuplot is searched from path, but can be overridden with the # environment variable "GNUPLOT" gnuplot_cmd = 'gnuplot' if 'GNUPLOT' in os.environ: gnuplot_cmd = os.environ['GNUPLOT'] conf = { 'max_domains': 10, 'max_ext_length': 10, 'style': 'gitstats.css', 'max_authors': 20, 'authors_top': 5, 'commit_begin': '', 'commit_end': 'HEAD', 'linear_linestats': 1, 'project_name': '', 'processes': 8, 'start_date': '', 'debug': False, 'verbose': False } def getpipeoutput(cmds, quiet = False): global exectime_external start = time.time() # Basic input validation to prevent command injection for cmd in cmds: if not isinstance(cmd, str): raise TypeError("Commands must be strings") # Check for obvious command injection attempts if any(dangerous in cmd for dangerous in [';', '&&', '||', '`', '$(']): print(f'Warning: Potentially dangerous command detected: {cmd}') if (not quiet and ON_LINUX and os.isatty(1)) or conf['verbose']: print('>> ' + ' | '.join(cmds), end='') sys.stdout.flush() p = subprocess.Popen(cmds[0], stdout = subprocess.PIPE, shell = True) processes=[p] for x in cmds[1:]: p = subprocess.Popen(x, stdin = p.stdout, stdout = subprocess.PIPE, shell = True) processes.append(p) output = p.communicate()[0] for p in processes: p.wait() end = time.time() if not quiet or conf['verbose'] or conf['debug']: if ON_LINUX and os.isatty(1): print('\r', end='') print('[%.5f] >> %s' % (end - start, ' | '.join(cmds))) if conf['debug']: print(f'DEBUG: Command output ({len(output)} bytes): {output[:200].decode("utf-8", errors="replace")}...') exectime_external += (end - start) return output.decode('utf-8', errors='replace').rstrip('\n') def getlogrange(defaultrange = 'HEAD', end_only = True): commit_range = getcommitrange(defaultrange, end_only) if len(conf['start_date']) > 0: return '--since="%s" "%s"' % (conf['start_date'], commit_range) return commit_range def getcommitrange(defaultrange = 'HEAD', end_only = False): if len(conf['commit_end']) > 0: if end_only or len(conf['commit_begin']) == 0: return conf['commit_end'] return '%s..%s' % (conf['commit_begin'], conf['commit_end']) return defaultrange def getkeyssortedbyvalues(dict): return list(map(lambda el : el[1], sorted(map(lambda el : (el[1], el[0]), dict.items())))) # dict['author'] = { 'commits': 512 } - ...key(dict, 'commits') def getkeyssortedbyvaluekey(d, key): return list(map(lambda el : el[1], sorted(map(lambda el : (d[el][key], el), d.keys())))) def getstatsummarycounts(line): numbers = re.findall(r'\d+', line) if len(numbers) == 1: # neither insertions nor deletions: may probably only happen for "0 files changed" numbers.append(0); numbers.append(0); elif len(numbers) == 2 and line.find('(+)') != -1: numbers.append(0); # only insertions were printed on line elif len(numbers) == 2 and line.find('(-)') != -1: numbers.insert(1, 0); # only deletions were printed on line return numbers VERSION = 0 def getversion(): global VERSION if VERSION == 0: gitstats_repo = os.path.dirname(os.path.abspath(__file__)) VERSION = getpipeoutput(["git --git-dir=%s/.git --work-tree=%s rev-parse --short %s" % (gitstats_repo, gitstats_repo, getcommitrange('HEAD').split('\n')[0])]) return VERSION def getgitversion(): return getpipeoutput(['git --version']).split('\n')[0] def getgnuplotversion(): return getpipeoutput(['%s --version' % gnuplot_cmd]).split('\n')[0] def getnumoffilesfromrev(time_rev): """ Get number of files changed in commit """ time, rev = time_rev return (int(time), rev, int(getpipeoutput(['git ls-tree -r --name-only "%s"' % rev, 'wc -l']).split('\n')[0])) def getnumoflinesinblob(ext_blob): """ Get number of lines in blob """ ext, blob_id = ext_blob return (ext, blob_id, int(getpipeoutput(['git cat-file blob %s' % blob_id, 'wc -l']).split()[0])) def analyzesloc(ext_blob): """ Analyze source lines of code vs comments vs blank lines in a blob Returns (ext, blob_id, total_lines, source_lines, comment_lines, blank_lines) """ ext, blob_id = ext_blob content = getpipeoutput(['git cat-file blob %s' % blob_id]) total_lines = 0 source_lines = 0 comment_lines = 0 blank_lines = 0 # Define comment patterns for different file types comment_patterns = { '.py': [r'^\s*#', r'^\s*"""', r'^\s*\'\'\''], '.js': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.ts': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.java': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.cpp': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.c': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.h': [r'^\s*//', r'^\s*/\*', r'^\s*\*'], '.css': [r'^\s*/\*', r'^\s*\*'], '.html': [r'^\s*