"""# Git-based Time Tracker. Quick and dirty time tracker on git histories. Uses the simple heuristics that each commit takes precisely one hour of work. It will automatically trim commits below one hour if another commit occurred less than an hour ago. Usage: ``` python -m git_time_tracker REPO1 REPO2... ``` # Obligatory This tool reports: ``` project Jmaa/git-time-tracker.git 3h 33m (2024) ``` And the ([Hamster](https://github.com/projecthamster/hamster)) manual time tracker reports: ![](docs/obligatory-hamster.png) """ import argparse import datetime import logging import sys from collections.abc import Iterator from pathlib import Path from .data import WorkSample, HIDDEN_LABEL_PREFIX, HIDDEN_LABEL_TOTAL from .source import git_repo logger = logging.getLogger(__name__) DEFAULT_EST_TIME = datetime.timedelta(hours=1) ZERO_DURATION = datetime.timedelta(seconds=0) HOUR = datetime.timedelta(hours=1) MINUTE = datetime.timedelta(minutes=1) def fmt_year_ranges_internal(years: list[int]) -> Iterator[str]: years = sorted(years) for idx, year in enumerate(years): at_end = idx == len(years) - 1 range_before = idx > 0 and years[idx-1] == year - 1 range_after = not at_end and years[idx+1] == year + 1 if not range_before or not range_after: yield str(year) if not at_end: if not range_before and range_after: yield '-' elif not range_after: yield ',' def fmt_year_ranges(years: list[int]) -> str: return ''.join(list(fmt_year_ranges_internal(years))) def fmt_line(label_type: str, label: str, total_time: datetime.timedelta) -> str: hours = int(total_time / HOUR) minutes = int((total_time - hours*HOUR)/MINUTE) return f' {label_type:10} {label:40} {hours:-4d}h {minutes:-2d}m' def generate_report(samples: list[WorkSample], sample_filter = frozenset()) -> Iterator[str]: LABEL_FILTER = {} # Time spent per label time_per_label: dict[str, datetime.timedelta] = {} years_per_label: dict[str, set[int]] = {} prev_time = datetime.datetime.fromtimestamp(0, datetime.UTC) for sample in samples: est_time: datetime.timedelta = DEFAULT_EST_TIME est_time = min(sample.registered_at - prev_time, est_time) if len(sample_filter) == 0: pass elif not set(sample.labels).intersection(sample_filter): continue for label in sample.labels: time_per_label.setdefault(label, ZERO_DURATION) time_per_label[label] += est_time years_per_label.setdefault(label,set()).add(sample.registered_at.year) prev_time = sample.registered_at del sample, est_time time_and_label = [(duration, label) for label, duration in time_per_label.items()] time_and_label.sort(reverse=True) # yield '-' * 66 yield '\n' for total_time, label_and_type in time_and_label: if label_and_type.startswith(HIDDEN_LABEL_PREFIX): continue label_type, label = label_and_type.split(':', 1) if len(LABEL_FILTER) > 0 and label_type not in LABEL_FILTER: continue yield fmt_line(label_type, label, total_time) yield ' (' yield fmt_year_ranges(years_per_label.get(label_and_type,[])) yield ')' yield '\n' del label, total_time yield '-' * 66 yield '\n' yield fmt_line('', 'TOTAL', time_per_label.get(HIDDEN_LABEL_TOTAL, ZERO_DURATION)) yield '\n' def parse_arguments(): parser = argparse.ArgumentParser() parser.add_argument('--git-repo', action='extend', nargs='+', type=Path, dest='repositories') parser.add_argument('--filter', action='extend', nargs='+', type=str, dest='sample_filter', default=[]) return parser.parse_args() def main(): logging.basicConfig() args = parse_arguments() shared_time_stamps: set[WorkSample] = set() for repo_path in args.repositories: logger.warning('Visit %s', repo_path) shared_time_stamps |= set(git_repo.iterate_samples_from_git_repository(repo_path)) shared_time_stamps = sorted(shared_time_stamps) for t in generate_report(shared_time_stamps, sample_filter=args.sample_filter): sys.stdout.write(t)