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
42 changes: 39 additions & 3 deletions gitprivacy/cli/pushcheck.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sys
import os

from datetime import datetime
from typing import List, Optional, Set

import click
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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:
# <local ref> SP <local sha1> SP <remote ref> SP <remote sha1> LF
Expand Down Expand Up @@ -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)

Expand Down
48 changes: 45 additions & 3 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 @@ -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
Expand All @@ -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
15 changes: 13 additions & 2 deletions gitprivacy/gitprivacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"""
Expand Down
8 changes: 8 additions & 0 deletions gitprivacy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ")
Expand Down
57 changes: 57 additions & 0 deletions tests/test_gitprivacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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)