Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
43 changes: 41 additions & 2 deletions gitprivacy/dateredacter/reduce.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
from datetime import datetime
from datetime import datetime, timedelta
import re

from . import DateRedacter


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
Expand All @@ -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:
Expand All @@ -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
22 changes: 20 additions & 2 deletions gitprivacy/gitprivacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,20 @@
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)

Check failure on line 62 in gitprivacy/gitprivacy.py

View workflow job for this annotation

GitHub Actions / build

ruff (F821)

gitprivacy/gitprivacy.py:62:17: F821 Undefined name `ctx`
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(
Expand Down Expand Up @@ -93,7 +106,12 @@
"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)

Check failure on line 113 in gitprivacy/gitprivacy.py

View workflow job for this annotation

GitHub Actions / build

ruff (F821)

gitprivacy/gitprivacy.py:113:13: F821 Undefined name `ctx`
return redacter

def write_config(self, **kwargs):
"""Write config"""
Expand Down
50 changes: 50 additions & 0 deletions tests/test_gitprivacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions tests/test_timestamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading