1
0
This repository has been archived on 2024-10-13. You can view files and clone it, but cannot push or open issues or pull requests.
git-time-tracker/git_time_tracker/__init__.py

200 lines
5.6 KiB
Python
Raw Normal View History

2024-07-09 22:15:45 +00:00
"""# 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)
"""
2024-06-03 20:42:05 +00:00
import argparse
2024-06-03 21:01:45 +00:00
import dataclasses
import datetime
2024-06-08 12:43:44 +00:00
import logging
import sys
import time
2024-06-03 21:01:45 +00:00
from collections.abc import Iterator, Sequence
2024-06-03 21:13:16 +00:00
from pathlib import Path
2024-06-03 20:42:05 +00:00
2024-06-08 12:43:44 +00:00
import git
2024-06-03 22:15:18 +00:00
logger = logging.getLogger(__name__)
2024-06-08 12:43:44 +00:00
2024-06-03 21:49:42 +00:00
@dataclasses.dataclass(frozen=True, order=True)
2024-06-03 21:01:45 +00:00
class WorkSample:
registered_at: datetime.datetime
labels: Sequence[str]
2024-06-03 22:02:49 +00:00
def determine_default(repo: git.Repo):
try:
repo.commit('main')
return 'main'
except:
return 'master'
2024-06-08 12:43:44 +00:00
2024-06-03 22:02:49 +00:00
HIDDEN_LABEL_PREFIX = '__'
HIDDEN_LABEL_TOTAL = HIDDEN_LABEL_PREFIX + 'TOTAL'
2024-06-08 12:43:44 +00:00
2024-06-03 22:15:18 +00:00
def determine_project_name(repo: git.Repo) -> str:
remotes = repo.remotes
if len(remotes) > 0:
return remotes.origin.url.removeprefix('git@gitfub.space:')
return Path(repo.working_tree_dir).name
2024-06-08 12:43:44 +00:00
2024-06-03 21:01:45 +00:00
def get_samples_from_project(repo: git.Repo) -> Iterator[WorkSample]:
2024-06-03 22:30:55 +00:00
project_name = determine_project_name(repo)
assert project_name is not None
2024-06-03 21:01:45 +00:00
2024-06-03 21:13:16 +00:00
# TODO: Branch on main or master or default
2024-06-03 22:02:49 +00:00
repo.commit()
for commit in repo.iter_commits(determine_default(repo)):
2024-06-03 22:30:55 +00:00
labels = [HIDDEN_LABEL_TOTAL]
labels.append('project:' + project_name)
2024-06-08 12:43:44 +00:00
labels.append('author:' + commit.author.email)
yield WorkSample(
datetime.datetime.fromtimestamp(commit.authored_date, tz=datetime.UTC),
tuple(labels),
)
yield WorkSample(
datetime.datetime.fromtimestamp(commit.committed_date, tz=datetime.UTC),
tuple(labels),
)
2024-06-03 22:30:55 +00:00
del labels
2024-06-03 20:42:05 +00:00
2024-06-03 21:49:42 +00:00
2024-06-08 12:43:44 +00:00
DEFAULT_EST_TIME = datetime.timedelta(hours=1)
ZERO_DURATION = datetime.timedelta(seconds=0)
HOUR = datetime.timedelta(hours=1)
2024-06-08 12:49:26 +00:00
MINUTE = datetime.timedelta(minutes=1)
2024-06-08 12:43:44 +00:00
2024-06-03 21:49:42 +00:00
2024-06-08 13:09:57 +00:00
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)))
2024-06-08 12:49:26 +00:00
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'
2024-06-08 12:49:26 +00:00
def generate_report(samples: list[WorkSample], sample_filter = frozenset()) -> Iterator[str]:
2024-06-03 22:46:15 +00:00
LABEL_FILTER = {}
2024-06-03 21:49:42 +00:00
# Time spent per label
time_per_label: dict[str, datetime.timedelta] = {}
2024-06-08 13:09:57 +00:00
years_per_label: dict[str, set[int]] = {}
2024-06-03 21:49:42 +00:00
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:
2024-06-03 22:30:55 +00:00
pass
elif not set(sample.labels).intersection(sample_filter):
2024-06-03 22:30:55 +00:00
continue
2024-06-03 21:49:42 +00:00
for label in sample.labels:
2024-06-08 12:43:44 +00:00
time_per_label.setdefault(label, ZERO_DURATION)
2024-06-03 21:49:42 +00:00
time_per_label[label] += est_time
2024-06-08 13:09:57 +00:00
years_per_label.setdefault(label,set()).add(sample.registered_at.year)
2024-06-03 21:49:42 +00:00
prev_time = sample.registered_at
del sample, est_time
2024-06-08 12:43:44 +00:00
time_and_label = [(duration, label) for label, duration in time_per_label.items()]
2024-06-03 22:02:49 +00:00
time_and_label.sort(reverse=True)
2024-06-03 21:49:42 +00:00
#
2024-06-03 22:30:55 +00:00
yield '-' * 66
2024-06-03 22:02:49 +00:00
yield '\n'
2024-06-08 12:43:44 +00:00
for total_time, label_and_type in time_and_label:
2024-06-03 22:30:55 +00:00
if label_and_type.startswith(HIDDEN_LABEL_PREFIX):
2024-06-03 22:02:49 +00:00
continue
2024-06-03 22:30:55 +00:00
label_type, label = label_and_type.split(':', 1)
2024-06-03 22:46:15 +00:00
if len(LABEL_FILTER) > 0 and label_type not in LABEL_FILTER:
2024-06-03 22:30:55 +00:00
continue
2024-06-08 12:49:26 +00:00
yield fmt_line(label_type, label, total_time)
2024-06-08 13:09:57 +00:00
yield ' ('
yield fmt_year_ranges(years_per_label.get(label_and_type,[]))
yield ')'
yield '\n'
2024-06-03 21:49:42 +00:00
del label, total_time
2024-06-03 22:30:55 +00:00
yield '-' * 66
2024-06-03 21:49:42 +00:00
yield '\n'
2024-06-08 12:49:26 +00:00
yield fmt_line('', 'TOTAL', time_per_label.get(HIDDEN_LABEL_TOTAL, ZERO_DURATION))
2024-06-08 13:09:57 +00:00
yield '\n'
2024-06-03 22:02:49 +00:00
def parse_arguments():
parser = argparse.ArgumentParser()
parser.add_argument('repositories', action='extend', nargs='+', type=Path)
parser.add_argument('--filter', action='extend', nargs='+', type=str, dest='sample_filter', default=[])
return parser.parse_args()
2024-06-03 20:42:05 +00:00
def main():
2024-06-03 22:15:18 +00:00
logging.basicConfig()
2024-06-03 20:42:05 +00:00
args = parse_arguments()
2024-06-03 21:13:16 +00:00
shared_time_stamps: set[WorkSample] = set()
for repo_path in args.repositories:
2024-06-03 22:15:18 +00:00
try:
repo = git.Repo(repo_path)
except git.exc.InvalidGitRepositoryError:
logger.warning('Ignoring non-repo %s', repo_path)
continue
logger.warning('Visit %s', repo_path)
2024-06-03 21:13:16 +00:00
shared_time_stamps |= set(get_samples_from_project(repo))
2024-06-03 21:49:42 +00:00
shared_time_stamps = sorted(shared_time_stamps)
for t in generate_report(shared_time_stamps, sample_filter=args.sample_filter):
2024-06-03 21:49:42 +00:00
sys.stdout.write(t)