"""# 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 ( HIDDEN_LABEL_PREFIX, HIDDEN_LABEL_TOTAL, RealizedWorkSample, WorkSample, ) from .format import cli, icalendar from .source import git_repo, csv_file logger = logging.getLogger(__name__) DEFAULT_ESTIMATED_DURATION = datetime.timedelta(hours=1) ZERO_DURATION = datetime.timedelta(seconds=0) HOUR = datetime.timedelta(hours=1) MINUTE = datetime.timedelta(minutes=1) def filter_samples( samples: list[WorkSample], sample_filter: set[str], ) -> list[WorkSample]: assert len(sample_filter) > 0 return [s for s in samples if set(s.labels).intersection(sample_filter)] def heuristically_realize_samples( samples: list[WorkSample], ) -> Iterator[RealizedWorkSample]: """Secret sauce. Guarentees that: * No samples overlap. """ previous_sample_end = datetime.datetime.fromtimestamp(0, datetime.UTC) for sample in samples: end_at = sample.end_at assert previous_sample_end <= end_at, 'Iterating in incorrect order' # TODO: Allow end_at is None start_at = sample.start_at if start_at is None: estimated_duration: datetime.timedelta = DEFAULT_ESTIMATED_DURATION start_at = max(previous_sample_end, end_at - estimated_duration) del estimated_duration yield RealizedWorkSample(labels=sample.labels, end_at=end_at, start_at=start_at) previous_sample_end = sample.end_at del sample def parse_arguments(): parser = argparse.ArgumentParser() parser.add_argument( '--git-repo', action='extend', nargs='+', type=Path, dest='repositories', default=[], ) parser.add_argument( '--csv-file', action='extend', nargs='+', type=Path, dest='csv_files', default=[], ) parser.add_argument( '--filter', action='extend', nargs='+', type=str, dest='sample_filter', default=[], ) parser.add_argument( '--format', action='store', type=str, dest='format_mode', default='cli_report', choices=['cli_report', 'icalendar'], ) return parser.parse_args() def load_samples(args): shared_time_stamps_set: set[WorkSample] = set() # Git repositories for repo_path in args.repositories: logger.warning('Determine commits from %s', repo_path) shared_time_stamps_set |= set( git_repo.iterate_samples_from_git_repository(repo_path), ) del repo_path # CSV Files for csv_path in args.csv_files: logger.warning('Load samples from %s', csv_path) shared_time_stamps_set |= set( csv_file.iterate_samples_from_csv_file(csv_path), ) del csv_path return shared_time_stamps_set def main(): logging.basicConfig() args = parse_arguments() # Determine samples shared_time_stamps_set = load_samples(args) # Sort samples shared_time_stamps = sorted(shared_time_stamps_set, key=lambda s: s.end_at) del shared_time_stamps_set # Filter samples sample_filter = args.sample_filter if len(sample_filter) != 0: logger.warning('Filtering %s samples', len(shared_time_stamps)) shared_time_stamps = filter_samples(shared_time_stamps, sample_filter) logger.warning('Filtered down to %s samples', len(shared_time_stamps)) # Heuristic samples logger.warning('Realizing %s samples', len(shared_time_stamps)) shared_time_stamps = list(heuristically_realize_samples(shared_time_stamps)) # Output format if args.format_mode == 'cli_report': for t in cli.generate_report(shared_time_stamps): sys.stdout.write(t) elif args.format_mode == 'icalendar': icalendar.generate_icalendar_file( shared_time_stamps, file='./output/samples.ics', )