diff --git a/README.md b/README.md index 27a805e..901e32f 100644 --- a/README.md +++ b/README.md @@ -204,13 +204,20 @@ your last commit. If false, it will abort the commit. Default is true. _Note: This requires that the `pre-commit` hook is set by `git-privacy init`_. -### `privacy.limit` +### `privacy.limitHour` If set, redacted timestamps will be rounded towards the given interval. The format is `hh-hh` where `hh` is a value between 0 and 24. -Example: `limit = 9-17` means that commits at 17:30 (5:30pm) are set to 17:00. +Example: `limitHour = 9-17` means that commits at 17:30 (5:30pm) are set to 17:00. By default limits are disabled. +### `privacy.limitWeekday` +If set, redacted timestamps will be moved backwards to be on one of the provided weekdays. +The format is either an interval `w-w` or a comma separated list of weekdays 'w,w,w,w' where `w` is a value between 0 (monday) and 6 (sunday). + +Example: `limitWeekday = 0-4` means that commits on saturday and sunday will be set to the previous friday. +By default the weekday limit is disabled. + ### `privacy.mode` Currently, only the `reduce` mode is supported. Default is `reduce`. diff --git a/gitprivacy/cli/pushcheck.py b/gitprivacy/cli/pushcheck.py index 00055c7..60699e6 100644 --- a/gitprivacy/cli/pushcheck.py +++ b/gitprivacy/cli/pushcheck.py @@ -1,5 +1,7 @@ import sys +import os +from datetime import datetime from typing import List, Optional, Set import click @@ -11,6 +13,9 @@ NULL_HEX_SHA = '0000000000000000000000000000000000000000' TAG_PREFIX = "refs/tags/" +DISABLE_UNREDACTED = 'GITPRIVACY_DISABLE_PREPUSH_UNREDACTED' +DISABLE_LIMITS = 'GITPRIVACY_DISABLE_PREPUSH_LIMITS' + @click.command('pre-push', hidden=True) @click.argument('remote_name', type=str) @@ -27,8 +32,12 @@ def check_push(ctx: click.Context, remote_name: str, It is also lists if and which remote branches (other than the push target) already contain a version of those unredated commits and will thus diverge after a redate. + + If privacy.limit and/or privacy.limitDay is set pushes are aborted if they + happen outside of those limits. """ del remote_location + check_limits(ctx) # read references from stdin (cf. githooks) lines = sys.stdin.readlines() @@ -39,11 +48,34 @@ def check_push(ctx: click.Context, remote_name: str, # hence we cannot rely on sorting out that case here completely. ctx.exit(0) + check_unredacted(ctx, remote_name, lines) + + +def check_limits(ctx: click.Context) -> None: + if os.environ.get(DISABLE_LIMITS): + return + if utils.is_outside_limit(ctx.obj.get_dateredacter(), datetime.now()): + click.echo(click.wrap_text( + """ +WARNING: You're trying to push outside of the datetime limits configured for +git-privacy. Pushing to a remote might create timestamps on the git forge. + """), err=True) + click.echo(click.wrap_text( + "\nNote: To disable the limit check set the environment variable" + f" {DISABLE_LIMITS} to a non-empty value."), err=True) + click.echo(click.wrap_text( + "\nDANGEROUS: To push without any checks pass the '--no-verify' option" + " to git push."), err=True) + ctx.exit(1) + + +def check_unredacted(ctx: click.Context, remote_name: str, lines: list[str]): + if os.environ.get(DISABLE_UNREDACTED): + return for line in lines: check_push_line(ctx, remote_name, line) - def check_push_line(ctx: click.Context, remote_name: str, line: str) -> None: # stdin format: # SP SP SP LF @@ -132,8 +164,12 @@ def check_push_line(ctx: click.Context, remote_name: str, line: str) -> None: ), err=True) click.echo("\n".join(rbranches), err=True) click.echo(click.wrap_text( - "\nNote: To push them without a redate pass the '--no-verify'" - " option to git push." + "\nNote: To push them without a redate pass set the environment" + f" variable {DISABLE_UNREDACTED} to a non-empty value.\n" + ), err=True) + click.echo(click.wrap_text( + "DANGEROUS: To push without any checks pass the '--no-verify'" + " option to git push.\n" ), err=True) ctx.exit(1) diff --git a/gitprivacy/dateredacter/reduce.py b/gitprivacy/dateredacter/reduce.py index fe68915..29e6e65 100644 --- a/gitprivacy/dateredacter/reduce.py +++ b/gitprivacy/dateredacter/reduce.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import re from . import DateRedacter @@ -6,16 +6,38 @@ class ResolutionDateRedacter(DateRedacter): """Resolution reducing timestamp redacter.""" - def __init__(self, pattern="s", limit=None, mode="reduce"): + def __init__(self, pattern="s", limit=None, limit_day=None, mode="reduce"): self.mode = mode self.pattern = pattern self.limit = limit + self.limit_days = None if limit: try: match = re.search('([0-9]+)-([0-9]+)', str(limit)) self.limit = (int(match.group(1)), int(match.group(2))) except AttributeError: raise ValueError("Unexpected syntax for limit.") + if limit_day: + try: + limit_day = str(limit_day) + match = re.search('^([0-6])-([0-6])$', str(limit_day)) + if match: + start = int(match.group(1)) + end = int(match.group(2)) + if start > end: + raise ValueError("Start day can't be after end day for limit_day.") + self.limit_days = list(range(start, end + 1)) + self.limit_days = {num: True for num in range(start, end + 1)} + else: + limit_days = str(limit_day).split(',') + self.limit_days = {} + for day in limit_days: + day = int(day.strip()) + if day < 0 or day > 6: + raise ValueError("Day must be between 0 and 6 for limit_day.") + self.limit_days[day] = True + except AttributeError: + raise ValueError("Unexpected syntax for limit.") def redact(self, timestamp: datetime) -> datetime: """Reduces timestamp precision for the parts specifed by the pattern using @@ -33,9 +55,13 @@ def redact(self, timestamp: datetime) -> datetime: timestamp = timestamp.replace(minute=0) if "s" in self.pattern: timestamp = timestamp.replace(second=0) - timestamp = self._enforce_limit(timestamp) + timestamp = self.enforce_limits(timestamp) return timestamp + def enforce_limits(self, timestamp: datetime) -> datetime: + timestamp = self._enforce_limit(timestamp) + return self._enforce_limit_day(timestamp) + def _enforce_limit(self, timestamp: datetime) -> datetime: if not self.limit: return timestamp @@ -45,3 +71,19 @@ def _enforce_limit(self, timestamp: datetime) -> datetime: if timestamp.hour >= end: timestamp = timestamp.replace(hour=end, minute=0, second=0) return timestamp + + def _enforce_limit_day(self, timestamp: datetime) -> datetime: + if not self.limit_days: + return timestamp + + current_weekday = timestamp.weekday() + if current_weekday in self.limit_days: + return timestamp + + for days_back in range(1, 8): # Maximum 7 days to check all weekdays + check_day = (current_weekday - days_back) % 7 + if check_day in self.limit_days: + return timestamp - timedelta(days=days_back) + + # This should never happen if limit_days contains at least one weekday + return timestamp diff --git a/gitprivacy/gitprivacy.py b/gitprivacy/gitprivacy.py index d926aad..39eb8ea 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -47,7 +47,18 @@ def read_config(self): with self.repo.config_reader() as config: self.mode = config.get_value(self.SECTION, 'mode', 'reduce') self.pattern = config.get_value(self.SECTION, 'pattern', '') - self.limit = config.get_value(self.SECTION, 'limit', '') + self.limit = config.get_value(self.SECTION, "limitHour", config.get_value(self.SECTION, 'limit', '')) + if config.get_value(self.SECTION, 'limit', False): + click.echo(click.wrap_text( + 'The option privacy.limit is deprecated and will be removed in future versions.' + 'Use privacy.limitHour instead.' + )) + if config.get_value(self.SECTION, 'limit', False) and config.get_value(self.SECTION, 'limitHour', False): + click.echo(click.wrap_text( + 'Not allowed to use the deprecated privacy.limit and privacy.limitHour at the same time.' + 'Only use privacy.limitHour instead.' + )) + self.limitDay = config.get_value(self.SECTION, "limitWeekday", '') self.password = config.get_value(self.SECTION, 'password', '') self.salt = config.get_value(self.SECTION, 'salt', '') self.ignoreTimezone = bool(config.get_value( @@ -93,7 +104,7 @@ def get_dateredacter(self) -> DateRedacter: "following time unit identifiers: " "M: month, d: day, h: hour, m: minute, s: second.", preserve_paragraphs=True)) - return ResolutionDateRedacter(self.pattern, self.limit, self.mode) + return ResolutionDateRedacter(self.pattern, self.limit, self.limitDay, self.mode) def write_config(self, **kwargs): """Write config""" diff --git a/gitprivacy/utils.py b/gitprivacy/utils.py index d14b538..063f6a9 100644 --- a/gitprivacy/utils.py +++ b/gitprivacy/utils.py @@ -40,6 +40,14 @@ def is_already_redacted(redacter: dateredacter.DateRedacter, return False +def is_outside_limit(redacter: dateredacter.DateRedacter, + time: datetime): + """Check if the timestamp is within the set limits""" + limited = redacter.enforce_limits(time) + if limited != time: + return True + return False + def get_named_ref(commit: git.Commit) -> str: """Get a user-friendly named ref for the commit.""" _hexsha, name = commit.name_rev.split(" ") diff --git a/tests/test_gitprivacy.py b/tests/test_gitprivacy.py index 2f6ae60..9256296 100644 --- a/tests/test_gitprivacy.py +++ b/tests/test_gitprivacy.py @@ -1094,6 +1094,63 @@ def test_prepush_check(self): ) self.assertEqual(res, 0) + def test_prepush_limit_weekday_check(self): + with self.runner.isolated_filesystem(): + self.setUpRepo() + remote = self.setUpRemote() + # commit before git-privacy init to produce unredacted ts + self.addCommit("a") + self.setConfig() + self.git.config(["privacy.limitWeekday", str((datetime.now().weekday() - 1) % 7)]) + self._prepush_limit_check(remote) + + def test_prepush_limit_hour_check(self): + with self.runner.isolated_filesystem(): + self.setUpRepo() + remote = self.setUpRemote() + # commit before git-privacy init to produce unredacted ts + self.addCommit("a") + self.setConfig() + hour = str((datetime.now().hour - 1) % 24) + self.git.config(["privacy.limitHour", f'{hour}-{hour}']) + self._prepush_limit_check(remote) + + def _prepush_limit_check(self, remote): + result = self.invoke('init') + self.assertEqual(result.exit_code, 0) + # try to push them unredacted + with self.assertRaises(git.GitCommandError) as cm: + self.git.push( + [remote.name, self.repo.active_branch], + ) + self.assertEqual(cm.exception.status, 1) + self.assertIn( + 'WARNING: You\'re trying to push outside of the datetime limits configured', + cm.exception.stderr, + ) + # try to force-push them unredacted – should make no difference + with self.assertRaises(git.GitCommandError) as cm: + self.git.push( + ["--force", remote.name, self.repo.active_branch], + ) + self.assertEqual(cm.exception.status, 1) + self.assertIn( + 'WARNING: You\'re trying to push outside of the datetime limits configured', + cm.exception.stderr, + ) + # redate and then push – should not work + result = self.invoke('redate') + self.assertEqual(result.exit_code, 0) + with self.assertRaises(git.GitCommandError) as cm: + self.git.push( + [remote.name, self.repo.active_branch], + ) + self.assertEqual(cm.exception.status, 1) + self.assertIn( + 'WARNING: You\'re trying to push outside of the datetime limits configured', + cm.exception.stderr, + ) + def test_prepush_check_multiple_remotes(self): with self.runner.isolated_filesystem(): self.setUpRepo() diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py index ad959df..3bd28ec 100644 --- a/tests/test_timestamp.py +++ b/tests/test_timestamp.py @@ -58,3 +58,40 @@ def test_after(self): hour=17, minute=0, second=0) self.assertEqual(ts.limit, (9, 17)) self.assertEqual(ts._enforce_limit(full), expected) + +class LimitDayTestCase(unittest.TestCase): + def test_allowed_day(self): + ts = ResolutionDateRedacter(limit_day="0,1") + full = datetime(year=2026, month=2, day=16, + hour=8, minute=42, second=15) + expected = datetime(year=2026, month=2, day=16, + hour=8, minute=42, second=15) + self.assertEqual(ts.limit_days, {0: True, 1: True}) + self.assertEqual(ts._enforce_limit_day(full), expected) + + def test_no_wrap(self): + ts = ResolutionDateRedacter(limit_day="0, 1") + full = datetime(year=2026, month=2, day=22, + hour=17, minute=42, second=15) + expected = datetime(year=2026, month=2, day=17, + hour=17, minute=42, second=15) + self.assertEqual(ts.limit_days, {0: True, 1: True}) + self.assertEqual(ts._enforce_limit_day(full), expected) + + def test_wrap_weekday(self): + ts = ResolutionDateRedacter(limit_day="1") + full = datetime(year=2026, month=2, day=16, + hour=8, minute=42, second=15) + expected = datetime(year=2026, month=2, day=10, + hour=8, minute=42, second=15) + self.assertEqual(ts.limit_days, {1: True}) + self.assertEqual(ts._enforce_limit_day(full), expected) + + def test_interval(self): + ts = ResolutionDateRedacter(limit_day="0-4") + full = datetime(year=2026, month=2, day=18, + hour=8, minute=42, second=15) + expected = datetime(year=2026, month=2, day=18, + hour=8, minute=42, second=15) + self.assertEqual(ts.limit_days, {0: True, 1: True, 2: True, 3: True, 4: True}) + self.assertEqual(ts._enforce_limit_day(full), expected)