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/dateredacter/reduce.py b/gitprivacy/dateredacter/reduce.py index fe68915..b826270 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 @@ -34,6 +56,7 @@ def redact(self, timestamp: datetime) -> datetime: if "s" in self.pattern: timestamp = timestamp.replace(second=0) timestamp = self._enforce_limit(timestamp) + timestamp = self._enforce_limit_day(timestamp) return timestamp def _enforce_limit(self, timestamp: datetime) -> datetime: @@ -45,3 +68,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..fab9780 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -47,7 +47,20 @@ 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.has_option(self.SECTION, 'limit'): + click.echo(click.wrap_text( + 'Warning: The option privacy.limit is deprecated and will be removed in future versions.' + 'Use privacy.limitHour instead.' + )) + if config.has_option(self.SECTION, 'limit') and config.has_option(self.SECTION, 'limitHour'): + click.echo(click.wrap_text( + 'Error: Not allowed to use the deprecated privacy.limit and privacy.limitHour at the same time.' + 'Only use privacy.limitHour instead.' + ), err=True) + ctx.exit(1) + 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 +106,12 @@ 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) + try: + redacter = ResolutionDateRedacter(self.pattern, self.limit, self.limitDay, self.mode) + except ValueError as e: + click.echo(click.wrap_text(str(e))) + ctx.exit(1) + return redacter def write_config(self, **kwargs): """Write config""" diff --git a/tests/test_gitprivacy.py b/tests/test_gitprivacy.py index 2f6ae60..fdbe1f1 100644 --- a/tests/test_gitprivacy.py +++ b/tests/test_gitprivacy.py @@ -1094,6 +1094,56 @@ def test_prepush_check(self): ) self.assertEqual(res, 0) + def test_limithour_config(self): + with self.runner.isolated_filesystem(): + self.setUpRepo() + self.setConfig() + self.addCommit("a") + self.git.config(["privacy.limit", "0-8"]) + result = self.invoke('redate') + self.assertIn("The option privacy.limit is deprecated", result.output) + self.assertEqual(result.exit_code, 0) + self.addCommit("b") + self.git.config(["privacy.limitHour", "0-8"]) + result = self.invoke('redate') + self.assertIn( + "Error: Not allowed to use the deprecated", + result.output + ) + self.assertEqual(result.exit_code, 1) + self.git.config(["--unset", "privacy.limit"]) + self.git.config(["privacy.limitHour", "invalid"]) + result = self.invoke('redate') + self.assertIn( + "Unexpected syntax for limit.", + result.output + ) + self.assertEqual(result.exit_code, 1) + self.git.config(["privacy.limitHour", "20-24"]) + result = self.invoke('redate') + self.assertEqual(result.exit_code, 0) + + def test_limitweekday_config(self): + with self.runner.isolated_filesystem(): + self.setUpRepo() + self.setConfig() + self.addCommit("a") + self.git.config(["privacy.limitWeekday", "1-0"]) + result = self.invoke('redate') + self.assertIn("Start day can't be after end day for limit_day.", result.output) + self.assertEqual(result.exit_code, 1) + self.addCommit("b") + self.git.config(["privacy.limitWeekday", "0,1,7"]) + result = self.invoke('redate') + self.assertIn( + "Day must be between 0 and 6 for limit_day.", + result.output + ) + self.assertEqual(result.exit_code, 1) + self.git.config(["privacy.limitWeekday", "0, 1, 2,3,4,5,6"]) + result = self.invoke('redate') + self.assertEqual(result.exit_code, 0) + 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)