From 3532a5cef64afae391a998a169b762f3921199a3 Mon Sep 17 00:00:00 2001 From: fap <459631+fapdash@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:14:06 +0100 Subject: [PATCH 1/4] Feature: Add weekday limit --- README.md | 7 +++++ gitprivacy/dateredacter/reduce.py | 43 +++++++++++++++++++++++++++++-- gitprivacy/gitprivacy.py | 3 ++- tests/test_timestamp.py | 37 ++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27a805e..c0ebe6a 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,13 @@ 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. By default limits are disabled. +### `privacy.limitDay` +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: `limitDays = 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..db8670d 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -48,6 +48,7 @@ def read_config(self): 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.limitDay = config.get_value(self.SECTION, "limitDay", '') self.password = config.get_value(self.SECTION, 'password', '') self.salt = config.get_value(self.SECTION, 'salt', '') self.ignoreTimezone = bool(config.get_value( @@ -93,7 +94,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/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) From 28e66c7cf3562ce8cd7db26ad8a8eb86df97a3f0 Mon Sep 17 00:00:00 2001 From: fap <459631+fapdash@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:31:26 +0100 Subject: [PATCH 2/4] Rename privacy.limit to privacy.limitHour --- README.md | 4 ++-- gitprivacy/gitprivacy.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c0ebe6a..7f6402e 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,11 @@ 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.limitDay` diff --git a/gitprivacy/gitprivacy.py b/gitprivacy/gitprivacy.py index db8670d..7c7b93f 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -47,7 +47,17 @@ 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, "limitDay", '') self.password = config.get_value(self.SECTION, 'password', '') self.salt = config.get_value(self.SECTION, 'salt', '') From 16c3ec71d4c679f17f4aa830350daffd953e49f5 Mon Sep 17 00:00:00 2001 From: fap <459631+fapdash@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:12:59 +0100 Subject: [PATCH 3/4] Rename privacy.limitDay to privacy.limitWeekday --- README.md | 4 ++-- gitprivacy/gitprivacy.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7f6402e..901e32f 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,11 @@ The format is `hh-hh` where `hh` is a value between 0 and 24. Example: `limitHour = 9-17` means that commits at 17:30 (5:30pm) are set to 17:00. By default limits are disabled. -### `privacy.limitDay` +### `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: `limitDays = 0-4` means that commits on saturday and sunday will be set to the previous friday. +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` diff --git a/gitprivacy/gitprivacy.py b/gitprivacy/gitprivacy.py index 7c7b93f..39eb8ea 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -58,7 +58,7 @@ def read_config(self): '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, "limitDay", '') + 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( From 883a8a3ac2fc6ae4355a528c6ebd1f4f7dfc421e Mon Sep 17 00:00:00 2001 From: fap <459631+fapdash@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:14:20 +0100 Subject: [PATCH 4/4] Add tests for limit config - Catching ValueErrors from ResolutionDateRedacter constructor and report the error message now. Previously those error messages didn't get reported to the user. --- gitprivacy/gitprivacy.py | 21 +++++++++++------ tests/test_gitprivacy.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/gitprivacy/gitprivacy.py b/gitprivacy/gitprivacy.py index 39eb8ea..fab9780 100755 --- a/gitprivacy/gitprivacy.py +++ b/gitprivacy/gitprivacy.py @@ -47,17 +47,19 @@ 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, "limitHour", config.get_value(self.SECTION, 'limit', '')) - if config.get_value(self.SECTION, 'limit', False): + 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( - 'The option privacy.limit is deprecated and will be removed in future versions.' + 'Warning: 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): + if config.has_option(self.SECTION, 'limit') and config.has_option(self.SECTION, 'limitHour'): click.echo(click.wrap_text( - 'Not allowed to use the deprecated privacy.limit and privacy.limitHour at the same time.' + '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', '') @@ -104,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.limitDay, 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()