├── .github └── workflows │ └── pytest.yml ├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── gitprivacy ├── __init__.py ├── cli │ ├── __init__.py │ ├── email.py │ ├── keys.py │ ├── pushcheck.py │ └── utils.py ├── crypto │ ├── __init__.py │ ├── passwordsecretbox.py │ └── secretbox.py ├── dateredacter │ ├── __init__.py │ └── reduce.py ├── encoder │ ├── __init__.py │ └── msgembed.py ├── gitprivacy.py ├── resources │ ├── __init__.py │ └── hooks │ │ ├── __init__.py │ │ ├── post-commit │ │ ├── post-rewrite │ │ ├── pre-commit │ │ └── pre-push ├── rewriter │ ├── __init__.py │ ├── amendrewriter.py │ └── filterrewriter.py └── utils.py ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── data ├── commit_cipher_combined ├── commit_cipher_dedicated ├── commit_cipher_diffpwds └── commit_cipher_mixed ├── test_crypto.py ├── test_gitprivacy.py └── test_timestamp.py /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Pytest 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install pytest 26 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 27 | python -m pip install -e . 28 | - name: Test with pytest 29 | run: | 30 | pytest 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=1 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist=numpy 36 | 37 | # Allow optimization of some AST trees. This will activate a peephole AST 38 | # optimizer, which will apply various small optimizations. For instance, it can 39 | # be used to obtain the result of joining multiple strings with the addition 40 | # operator. Joining a lot of strings can lead to a maximum recursion error in 41 | # Pylint and this flag can prevent that. It has one side effect, the resulting 42 | # AST will be different than the one from reality. This option is deprecated 43 | # and it will be removed in Pylint 2.0. 44 | optimize-ast=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Enable the message, report, category or checker with the given id(s). You can 54 | # either give multiple identifier separated by comma (,) or put this option 55 | # multiple time (only on the command line, not in the configuration file where 56 | # it should appear only once). See also the "--disable" option for examples. 57 | #enable= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once).You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use"--disable=all --enable=classes 67 | # --disable=W" 68 | disable=long-suffix,standarderror-builtin,indexing-exception,delslice-method,unichr-builtin,dict-view-method,parameter-unpacking,unicode-builtin,cmp-builtin,intern-builtin,round-builtin,backtick,nonzero-method,xrange-builtin,coerce-method,raw_input-builtin,old-division,filter-builtin-not-iterating,old-octal-literal,input-builtin,map-builtin-not-iterating,buffer-builtin,basestring-builtin,zip-builtin-not-iterating,using-cmp-argument,unpacking-in-except,old-raise-syntax,coerce-builtin,dict-iter-method,hex-method,range-builtin-not-iterating,useless-suppression,cmp-method,print-statement,reduce-builtin,file-builtin,long-builtin,getslice-method,execfile-builtin,no-absolute-import,metaclass-assignment,oct-method,reload-builtin,import-star-module-level,suppressed-message,apply-builtin,raising-string,next-method-called,setslice-method,old-ne-operator,arguments-differ,wildcard-import,locally-disabled,wrong-import-order,missing-module-docstring,missing-class-docstring,missing-function-docstring,wrong-import-position 69 | 70 | 71 | [REPORTS] 72 | 73 | # Set the output format. Available formats are text, parseable, colorized, msvs 74 | # (visual studio) and html. You can also give a reporter class, eg 75 | # mypackage.mymodule.MyReporterClass. 76 | output-format=text 77 | 78 | # Put messages in a separate file for each module / package specified on the 79 | # command line instead of printing them on stdout. Reports (if any) will be 80 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 81 | # and it will be removed in Pylint 2.0. 82 | files-output=no 83 | 84 | # Tells whether to display a full report or only the messages 85 | reports=yes 86 | 87 | # Python expression which should return a note less than 10 (10 is the highest 88 | # note). You have access to the variables errors warning, statement which 89 | # respectively contain the number of errors / warnings messages and the total 90 | # number of statements analyzed. This is used by the global evaluation report 91 | # (RP0004). 92 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 93 | 94 | # Template used to display messages. This is a python new-style format string 95 | # used to format the message information. See doc for all details 96 | #msg-template= 97 | 98 | 99 | [BASIC] 100 | 101 | # Good variable names which should always be accepted, separated by a comma 102 | good-names=i,j,k,ex,Run,_,f 103 | 104 | # Bad variable names which should always be refused, separated by a comma 105 | bad-names=foo,bar,baz,toto,tutu,tata 106 | 107 | # Colon-delimited sets of names that determine each other's naming style when 108 | # the name regexes allow several styles. 109 | name-group= 110 | 111 | # Include a hint for the correct naming format with invalid-name 112 | include-naming-hint=no 113 | 114 | # List of decorators that produce properties, such as abc.abstractproperty. Add 115 | # to this list to register other decorators that produce valid properties. 116 | property-classes=abc.abstractproperty 117 | 118 | # Regular expression matching correct variable names 119 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 120 | 121 | # Naming hint for variable names 122 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 123 | 124 | # Regular expression matching correct class attribute names 125 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 126 | 127 | # Naming hint for class attribute names 128 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 129 | 130 | # Regular expression matching correct argument names 131 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 132 | 133 | # Naming hint for argument names 134 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 135 | 136 | # Regular expression matching correct module names 137 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 138 | 139 | # Naming hint for module names 140 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 141 | 142 | # Regular expression matching correct constant names 143 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 144 | 145 | # Naming hint for constant names 146 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 147 | 148 | # Regular expression matching correct inline iteration names 149 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 150 | 151 | # Naming hint for inline iteration names 152 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 153 | 154 | # Regular expression matching correct method names 155 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 156 | 157 | # Naming hint for method names 158 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 159 | 160 | # Regular expression matching correct function names 161 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 162 | 163 | # Naming hint for function names 164 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 165 | 166 | # Regular expression matching correct attribute names 167 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 168 | 169 | # Naming hint for attribute names 170 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 171 | 172 | # Regular expression matching correct class names 173 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 174 | 175 | # Naming hint for class names 176 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 177 | 178 | # Regular expression which should only match function or class names that do 179 | # not require a docstring. 180 | no-docstring-rgx=^test_ 181 | 182 | # Minimum line length for functions/classes that require docstrings, shorter 183 | # ones are exempt. 184 | docstring-min-length=-1 185 | 186 | 187 | [ELIF] 188 | 189 | # Maximum number of nested blocks for function / method body 190 | max-nested-blocks=5 191 | 192 | 193 | [FORMAT] 194 | 195 | # Maximum number of characters on a single line. 196 | max-line-length=80 197 | 198 | # Regexp for a line that is allowed to be longer than the limit. 199 | ignore-long-lines=^\s*(# )??$ 200 | 201 | # Allow the body of an if to be on the same line as the test if there is no 202 | # else. 203 | single-line-if-stmt=y 204 | 205 | # List of optional constructs for which whitespace checking is disabled. `dict- 206 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 207 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 208 | # `empty-line` allows space-only lines. 209 | no-space-check=trailing-comma,dict-separator 210 | 211 | # Maximum number of lines in a module 212 | max-module-lines=1000 213 | 214 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 215 | # tab). 216 | indent-string=' ' 217 | 218 | # Number of spaces of indent required inside a hanging or continued line. 219 | indent-after-paren=4 220 | 221 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 222 | expected-line-ending-format= 223 | 224 | 225 | [LOGGING] 226 | 227 | # Logging modules to check that the string format arguments are in logging 228 | # function parameter format 229 | logging-modules=logging 230 | 231 | 232 | [MISCELLANEOUS] 233 | 234 | # List of note tags to take in consideration, separated by a comma. 235 | notes=FIXME,XXX,TODO 236 | 237 | 238 | [SIMILARITIES] 239 | 240 | # Minimum lines number of a similarity. 241 | min-similarity-lines=10 242 | 243 | # Ignore comments when computing similarities. 244 | ignore-comments=yes 245 | 246 | # Ignore docstrings when computing similarities. 247 | ignore-docstrings=yes 248 | 249 | # Ignore imports when computing similarities. 250 | ignore-imports=no 251 | 252 | 253 | [SPELLING] 254 | 255 | # Spelling dictionary name. Available dictionaries: none. To make it working 256 | # install python-enchant package. 257 | spelling-dict= 258 | 259 | # List of comma separated words that should not be checked. 260 | spelling-ignore-words= 261 | 262 | # A path to a file that contains private dictionary; one word per line. 263 | spelling-private-dict-file= 264 | 265 | # Tells whether to store unknown words to indicated private dictionary in 266 | # --spelling-private-dict-file option instead of raising a message. 267 | spelling-store-unknown-words=no 268 | 269 | 270 | [TYPECHECK] 271 | 272 | # Tells whether missing members accessed in mixin class should be ignored. A 273 | # mixin class is detected if its name ends with "mixin" (case insensitive). 274 | ignore-mixin-members=yes 275 | 276 | # List of module names for which member attributes should not be checked 277 | # (useful for modules/projects where namespaces are manipulated during runtime 278 | # and thus existing member attributes cannot be deduced by static analysis. It 279 | # supports qualified module names, as well as Unix pattern matching. 280 | ignored-modules= 281 | 282 | # List of class names for which member attributes should not be checked (useful 283 | # for classes with dynamically set attributes). This supports the use of 284 | # qualified names. 285 | ignored-classes=optparse.Values,thread._local,_thread._local,matplotlib.cm,tensorflow.python,tensorflow,tensorflow.train.Example,RunOptions 286 | 287 | # List of members which are set dynamically and missed by pylint inference 288 | # system, and so shouldn't trigger E1101 when accessed. Python regular 289 | # expressions are accepted. 290 | generated-members=set_shape,np.float32 291 | 292 | # List of decorators that produce context managers, such as 293 | # contextlib.contextmanager. Add to this list to register other decorators that 294 | # produce valid context managers. 295 | contextmanager-decorators=contextlib.contextmanager 296 | 297 | 298 | [VARIABLES] 299 | 300 | # Tells whether we should check for unused import in __init__ files. 301 | init-import=no 302 | 303 | # A regular expression matching the name of dummy variables (i.e. expectedly 304 | # not used). 305 | dummy-variables-rgx=(_+[a-zA-Z0-9_]*?$)|dummy 306 | 307 | # List of additional names supposed to be defined in builtins. Remember that 308 | # you should avoid to define new builtins when possible. 309 | additional-builtins= 310 | 311 | # List of strings which can identify a callback function by name. A callback 312 | # name must start or end with one of those strings. 313 | callbacks=cb_,_cb 314 | 315 | # List of qualified module names which can have objects that can redefine 316 | # builtins. 317 | redefining-builtins-modules=six.moves,future.builtins 318 | 319 | 320 | [CLASSES] 321 | 322 | # List of method names used to declare (i.e. assign) instance attributes. 323 | defining-attr-methods=__init__,__new__,setUp 324 | 325 | # List of valid names for the first argument in a class method. 326 | valid-classmethod-first-arg=cls 327 | 328 | # List of valid names for the first argument in a metaclass class method. 329 | valid-metaclass-classmethod-first-arg=mcs 330 | 331 | # List of member names, which should be excluded from the protected access 332 | # warning. 333 | exclude-protected=_asdict,_fields,_replace,_source,_make 334 | 335 | 336 | [DESIGN] 337 | 338 | # Maximum number of arguments for function / method 339 | max-args=10 340 | 341 | # Argument names that match this expression will be ignored. Default to name 342 | # with leading underscore 343 | ignored-argument-names=_.* 344 | 345 | # Maximum number of locals for function / method body 346 | max-locals=30 347 | 348 | # Maximum number of return / yield for function / method body 349 | max-returns=6 350 | 351 | # Maximum number of branch for function / method body 352 | max-branches=12 353 | 354 | # Maximum number of statements in function / method body 355 | max-statements=100 356 | 357 | # Maximum number of parents for a class (see R0901). 358 | max-parents=7 359 | 360 | # Maximum number of attributes for a class (see R0902). 361 | max-attributes=10 362 | 363 | # Minimum number of public methods for a class (see R0903). 364 | min-public-methods=0 365 | 366 | # Maximum number of public methods for a class (see R0904). 367 | max-public-methods=20 368 | 369 | # Maximum number of boolean expressions in a if statement 370 | max-bool-expr=5 371 | 372 | 373 | [IMPORTS] 374 | 375 | # Deprecated modules which should not be used, separated by a comma 376 | deprecated-modules=optparse 377 | 378 | # Create a graph of every (i.e. internal and external) dependencies in the 379 | # given file (report RP0402 must not be disabled) 380 | import-graph= 381 | 382 | # Create a graph of external dependencies in the given file (report RP0402 must 383 | # not be disabled) 384 | ext-import-graph= 385 | 386 | # Create a graph of internal dependencies in the given file (report RP0402 must 387 | # not be disabled) 388 | int-import-graph= 389 | 390 | # Force import order to recognize a module as part of the standard 391 | # compatibility libraries. 392 | known-standard-library= 393 | 394 | # Force import order to recognize a module as part of a third party library. 395 | known-third-party=enchant 396 | 397 | # Analyse import fallback blocks. This can be used to support both Python 2 and 398 | # 3 compatible code, which means that the block might have code that exists 399 | # only in one or another interpreter, leading to false positives when analysed. 400 | analyse-fallback-blocks=no 401 | 402 | 403 | [EXCEPTIONS] 404 | 405 | # Exceptions that will emit a warning when being caught. Defaults to 406 | # "Exception" 407 | overgeneral-exceptions=Exception 408 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | 7 | before_install: 8 | - sudo add-apt-repository -y ppa:git-core/ppa 9 | - sudo apt-get -q update 10 | - sudo apt-get -y install git 11 | 12 | install: 13 | - pip install -e . 14 | - pip install pytest 15 | script: 16 | - py.test 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Benjamin Brahmer 2 | Copyright (c) 2019, Christian Burkert 3 | 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | 8 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 9 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include gitprivacy/resources * 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest version on PyPI](https://img.shields.io/pypi/v/gitprivacy.svg)](https://pypi.org/project/gitprivacy/) 2 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/gitprivacy.svg)](https://pypi.org/project/gitprivacy/) 3 | [![Build Status](https://travis-ci.org/EMPRI-DEVOPS/git-privacy.svg?branch=master)](https://travis-ci.org/EMPRI-DEVOPS/git-privacy) 4 | 5 | # `git-privacy`: Keep your coding hours private 6 | 7 | `git-privacy` redacts author and committer dates to keep your coding hours more 8 | private. You can choose the level of redaction: only remove minutes and seconds 9 | from your dates or even hide day or month. 10 | The original dates are encrypted and stored in the commit message in case you 11 | might need them. 12 | 13 | 14 | ## Installation 15 | 16 | `git-privacy` can be easily installed via pip: 17 | 18 | $ pip3 install gitprivacy 19 | 20 | _Note: `git-privacy` requires Python version 3.6 or later and Git version 2.22.0 or later._ 21 | 22 | 23 | ## Getting Started 24 | 25 | You can either setup `git-privacy` separately for selected Git repositories or 26 | globally so that each new Git repo automatically uses `git-privacy` from the 27 | beginning. 28 | 29 | To setup `git-privacy` for a _single Git repository_ do the following: 30 | 31 | 1. Initialise `git-privacy`, which sets the necessary hooks. 32 | 33 | $ git-privacy init 34 | 35 | 2. Set a redaction pattern from the following options: 36 | - M: Sets the month to January 37 | - d: Sets the day to the first day of the month 38 | - h: Sets the hour to midnight 39 | - m: Sets the minute to zero (full hour) 40 | - s: Sets the seconds to zero (full minute) 41 | 42 | 43 | $ git config privacy.pattern 44 | 45 | For example: 46 | 47 | $ git config privacy.pattern hms 48 | 49 | 3. Set an encryption key if you want to preserve the original timestamps in 50 | encrypted form in the commit message. 51 | If no key is set, only the reduced timestamp will remain. 52 | 53 | $ git-privacy keys --init 54 | 55 | For more information about managing encryption keys see `git-privacy keys -h`. 56 | 57 | 4. Use Git as normal ;-) 58 | 59 | To setup `git-privacy` _globally for all new repositories_ do the following: 60 | 61 | 1. Initialise `git-privacy` to set the necessary hooks globally (in a Git 62 | template directory). 63 | 64 | $ git-privacy init --global 65 | 66 | 2. Set a global redaction pattern from the following options: 67 | - M: Sets the month to January 68 | - d: Sets the day to the first day of the month 69 | - h: Sets the hour to midnight 70 | - m: Sets the minute to zero (full hour) 71 | - s: Sets the seconds to zero (full minute) 72 | 73 | 74 | $ git config --global privacy.pattern 75 | 76 | For example: 77 | 78 | $ git config --global privacy.pattern hms 79 | 80 | 3. Use Git to init or clone repos as usual and `git-privacy` will redact 81 | timestamps for you :-) 82 | 83 | 4. **Per individual repo:** Set an encryption key if you want to preserve the original timestamps in 84 | encrypted form in the commit message. 85 | If no key is set, only the reduced timestamp will remain. 86 | 87 | $ git-privacy keys --init 88 | 89 | For more information about managing encryption keys see `git-privacy keys -h`. 90 | 91 | For an overview of further features and options read the following sections. 92 | 93 | 94 | ## Usage 95 | 96 | ### Redaction of New Commits 97 | 98 | New commits are automatically redacted if `git-privacy` is initialised in a repo. 99 | This is achieved via a post-commit hook. 100 | 101 | If you want to manually redact the last commit, run: 102 | 103 | $ git-privacy redate --only-head 104 | 105 | ### Bulk Re-dating of Commits 106 | 107 | To redact and redate all commits of the currently active branch run: 108 | 109 | $ git-privacy redate 110 | 111 | ***Warning: This will likely rewrite the Git history and might lead to 112 | diverging commit histories. 113 | See [A Warning about Git History Rewriting](#a-warning-about-git-history-rewriting).*** 114 | 115 | _Note: `git-privacy` warns you if you attempt to redate commits that have 116 | already been pushed to a remote. However, you can force it to do so anyway._ 117 | 118 | #### Re-dating of Commits from a Startpoint 119 | 120 | You can also limit the redate to all commits that succeed a given startpoint: 121 | 122 | $ git-privacy redate 123 | 124 | This will redate all commits in the range `..HEAD` (see git rev-list for syntax details). 125 | 126 | For example, you can use this to redate all commits of a branch since it has been branched off from `master` by invoking: 127 | 128 | $ git-privacy redate master 129 | 130 | ### View Unredacted Dates 131 | 132 | To view the unredacted commit dates, `git-privacy` offers a git-log-like listing: 133 | 134 | $ git-privacy log 135 | 136 | _Note: Unredacted dates are only preserved if you set an encryption key 137 | which allows `git-privacy` to store the encrypted dates in the commit 138 | message._ 139 | 140 | ### Redate after Rebases and other Rewrites 141 | Some Git operations like `git rebase` or `git commit --amend` rewrite existing 142 | commits. Consequently, a new commit date is set for those commit. 143 | `git-privacy` takes notice of such rewrites via a `post-rewrite` hook, logs 144 | them and alerts you that unredated commit dates have been introduced to the 145 | repository, as shown in the following example: 146 | 147 | $ git rebase -i HEAD~2 148 | Rebasing (1/2) 149 | Rebasing (2/2) 150 | A rewrite may have inserted unredacted committer dates. 151 | To apply date redaction on these dates run 152 | 153 | git-privacy redate-rewrites 154 | 155 | Warning: This alters your Git history. 156 | Successfully rebased and updated refs/heads/master. 157 | 158 | To redate the rewritten commits run, as mentioned: 159 | 160 | $ git-privacy redate-rewrites 161 | 162 | ***Warning: This will rewrite the Git history again and can lead to 163 | diverging commit histories. 164 | See [A Warning about Git History Rewriting](#a-warning-about-git-history-rewriting).*** 165 | 166 | 167 | ### Time Zone Change Warnings 168 | 169 | Git commit dates include your current system time zone. These time zones might 170 | leak information about your travel activities. 171 | `git-privacy` warns you about any changes in your system time zone since your last commit. 172 | By default, this is just a warning. 173 | You can set `git-privacy` to prevent commits with changed time zone by running 174 | 175 | $ git-privacy init --timezone-change=abort 176 | 177 | or by setting the `privacy.ignoreTimezone` switch in the Git config to `False`. 178 | 179 | 180 | ### Email Address Redaction 181 | 182 | Imagine you want to publish a repository which contains some contributor's private email addresses. 183 | `git-privacy` makes it easy to redact such addresses: 184 | 185 | $ git-privacy redact-email john@example.com paul@example.net 186 | 187 | You can also specify individual substitutes: 188 | 189 | $ git-privacy redact-email john@example.com:john@bigfirm.invalid 190 | 191 | Or, you can use your GitHub username and GitHub's [noreply addresses](https://help.github.com/en/articles/about-commit-email-addresses) to still have your commit associated to your account and get credit: 192 | 193 | $ git-privacy redact-email -g john@example.com:john 194 | 195 | 196 | ## Configuration Options 197 | 198 | `git-privacy` takes the following configuration options via [git-config](https://git-scm.com/docs/git-config). 199 | 200 | ### `privacy.ignoreTimezone` 201 | 202 | If true, `git-privacy` will only warn you if your timezone has changed since 203 | your last commit. If false, it will abort the commit. Default is true. 204 | 205 | _Note: This requires that the `pre-commit` hook is set by `git-privacy init`_. 206 | 207 | ### `privacy.limit` 208 | If set, redacted timestamps will be rounded towards the given interval. 209 | The format is `hh-hh` where `hh` is a value between 0 and 24. 210 | 211 | Example: `limit = 9-17` means that commits at 17:30 (5:30pm) are set to 17:00. 212 | By default limits are disabled. 213 | 214 | ### `privacy.mode` 215 | Currently, only the `reduce` mode is supported. Default is `reduce`. 216 | 217 | ### `privacy.password` 218 | _**Deprecated**: Since version 2.0, `git-privacy` uses key files to encrypt 219 | dates and will automatically migrate from passwords to the new format._ 220 | 221 | This specifies the password used to encrypt the original timestamps. 222 | If no password is given, original timestamps will not be preserved. 223 | 224 | ### `privacy.pattern` 225 | This option specifies the extend of the timestamp reduction applied by 226 | `git-privacy`. The reduction pattern is a string that can comprise the following characters: 227 | 228 | | Character | Meaning | 229 | | :-------- | :------ | 230 | | M | Sets the month to January | 231 | | d | Sets the day to the first day of the month | 232 | | h | Sets the hour to midnight | 233 | | m | Sets the minute to zero (full hour) | 234 | | s | Sets the seconds to zero (full minute) | 235 | 236 | If no pattern is specified, `git-privacy` will abort. 237 | 238 | ### `privacy.replacements` 239 | 240 | If true, `git-privacy` creates a replacement mapping ([git-replace(1)](https://git-scm.com/docs/git-replace)) 241 | for each commit that is rewritten by a redate. Default is false. 242 | 243 | ### `privacy.salt` 244 | _**Deprecated**: Since version 2.0, `git-privacy` uses key files to encrypt 245 | dates and will automatically migrate to the new format._ 246 | 247 | This is an auto-generated value created by `git-privacy` if `password` is set 248 | by the user. It should not be altered by the user. 249 | 250 | 251 | ## A Warning about Git History Rewriting 252 | 253 | Author and committer timestamps are part of the commit object and therefore part of 254 | the input that determines the commit hash (the commit's SHA1 value). 255 | Consequently, every modification to a timestamp changes the commit hash. 256 | And since Git uses hash chains, it also changes all commits that build on that 257 | commit, i.e., that have it as an ancestor. 258 | 259 | Example: Consider the commit sequence `a<-b<-c`. If you now redate `b`, 260 | `c` will also be rewritten under a new commit hash resulting into: `a<-b'<-c'`. 261 | If `b` and `c` were just commits in your local repository, you're probably 262 | fine. But if `b` or `c` have been shared with other developers (e.g., by 263 | pushing to a remote), your histories have diverged and you can no longer easily 264 | merge changes from remote. 265 | Do not force-push unless you absolutely know what you are doing! 266 | 267 | To avoid diverging histories `git-privacy` rejects redates of commits that 268 | are part of any known remote branch. 269 | But you can still run into locally diverging histories, e.g., if you redate 270 | after you have branched of a branch for a feature development. 271 | So keep this in mind when calling `git-privacy redate` manually. 272 | Using the automatic redating of new commits by the `post-commit` hook or by the 273 | `--only-head` option should be safe for standard setups. 274 | -------------------------------------------------------------------------------- /gitprivacy/__init__.py: -------------------------------------------------------------------------------- 1 | GIT_SUBDIR = "privacy" # subdir in .git used for storing state 2 | -------------------------------------------------------------------------------- /gitprivacy/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMPRI-DEVOPS/git-privacy/f14c10fff2be830310c5ac94d3dfa6ac93bee185/gitprivacy/cli/__init__.py -------------------------------------------------------------------------------- /gitprivacy/cli/email.py: -------------------------------------------------------------------------------- 1 | import click 2 | from collections import Counter 3 | from typing import List, Tuple 4 | 5 | from .utils import assertCommits 6 | 7 | 8 | class EmailRedactParamType(click.ParamType): 9 | name = 'emailredact' 10 | 11 | def convert(self, value, param, ctx) -> Tuple[str, str, str]: 12 | if ":" in value: 13 | try: 14 | old, new = value.split(":", maxsplit=1) 15 | name = "" 16 | if ":" in new: 17 | new, name = new.split(":") 18 | return (old, new, name) 19 | except ValueError: 20 | self.fail( 21 | f'{value} is not in the format ' 22 | 'old-email[:new-email[:new-name]]', 23 | param, ctx, 24 | ) 25 | return (value, "", "") 26 | 27 | 28 | EMAIL_REDACT = EmailRedactParamType() 29 | GHNOREPLY = "{username}@users.noreply.github.com" 30 | 31 | 32 | @click.command('redact-email') 33 | @click.argument('addresses', nargs=-1, type=EMAIL_REDACT) 34 | @click.option('-r', '--replacement', type=str, 35 | default="noreply@gitprivacy.invalid", 36 | help="Email address used as replacement.") 37 | @click.option('-g', '--use-github-noreply', 'use_ghnoreply', is_flag=True, 38 | help="Interpret custom replacements as GitHub usernames" 39 | " and construct noreply addresses.") 40 | @click.pass_context 41 | def redact_email(ctx: click.Context, 42 | addresses: List[Tuple[str, str, str]], 43 | replacement: str, 44 | use_ghnoreply: bool) -> None: 45 | """Redact email addresses from existing commits.""" 46 | ctx.obj.assert_repo() 47 | if not addresses: 48 | return # nothing to do 49 | 50 | assertCommits(ctx) 51 | repo = ctx.obj.repo 52 | 53 | env_cmd = "" 54 | with click.progressbar(addresses, 55 | label="Redacting emails") as bar: 56 | for old, new, name in bar: 57 | if new and use_ghnoreply: 58 | new = GHNOREPLY.format(username=new) 59 | if not new: 60 | new = replacement 61 | env_cmd += get_env_cmd("COMMITTER", old, new, name) 62 | env_cmd += get_env_cmd("AUTHOR", old, new, name) 63 | filter_cmd = ["git", "filter-branch", "-f", 64 | "--env-filter", env_cmd, 65 | "--", 66 | "HEAD"] 67 | repo.git.execute(command=filter_cmd) 68 | 69 | 70 | def get_env_cmd(role: str, old: str, new: str, name: str) -> str: 71 | name_env = f'GIT_{role}_NAME="{name}"' 72 | return ( 73 | f'if test "$GIT_{role}_EMAIL" = "{old}"; then ' 74 | f'export GIT_{role}_EMAIL="{new}" ' 75 | f'{name_env if name else ""}; ' 76 | 'fi; ' 77 | ) 78 | 79 | 80 | @click.command('list-email') 81 | @click.option('-a', '--all', 'check_all', is_flag=True, 82 | help="Include all local references.") 83 | @click.option('-e', '--email-only', is_flag=True, 84 | help="Only consider actors' email address when counting contributions.") 85 | @click.pass_context 86 | def list_email(ctx: click.Context, check_all: bool, email_only: bool) -> None: 87 | """List all author and committer identities.""" 88 | assertCommits(ctx) 89 | repo = ctx.obj.repo 90 | commits = repo.iter_commits("HEAD" if not check_all else "--all") 91 | authors: Counter[str] = Counter() 92 | committers: Counter[str] = Counter() 93 | if email_only: 94 | to_str = lambda a: a.email 95 | else: 96 | to_str = _actor_to_str 97 | for commit in commits: 98 | authors[to_str(commit.author)] += 1 99 | committers[to_str(commit.committer)] += 1 100 | total = authors + committers 101 | for actor in sorted(total): 102 | print(f"{actor} (total: {total[actor]}, author: {authors[actor]}, committer: {committers[actor]})") 103 | 104 | def _actor_to_str(actor): 105 | return f"{actor.name} <{actor.email}>" 106 | -------------------------------------------------------------------------------- /gitprivacy/cli/keys.py: -------------------------------------------------------------------------------- 1 | import click 2 | import git # type: ignore 3 | import os 4 | 5 | from typing import Iterator, Tuple 6 | 7 | import gitprivacy as gp 8 | 9 | from .. import crypto as gpcrypto 10 | from .. import gitprivacy as gpm 11 | 12 | 13 | KEY_DIR = os.path.join(gp.GIT_SUBDIR, "keys") 14 | KEY_CURRENT = os.path.join(KEY_DIR, "current") 15 | KEY_ARCHIVE = os.path.join(KEY_DIR, "archive") 16 | 17 | 18 | @click.command("keys") 19 | @click.option('--init', 'mode', flag_value='init', default=True, 20 | help="Generate an initial key. (Default mode)") 21 | @click.option('--new', 'mode', flag_value='new', 22 | help="Generate new key and archive the existing.") 23 | @click.option('--disable', 'mode', flag_value='disable', 24 | help="Disable and archive the active key.") 25 | @click.option('--migrate-pwd', 'mode', flag_value='migrate', 26 | help="Migrate from password-based encryption.") 27 | @click.option('--archive/--no-archive', default=True, 28 | show_default=True, 29 | help="Archive the replaced key instead of deleting it.") 30 | @click.pass_context 31 | def manage_keys(ctx: click.Context, mode: str, archive: bool) -> None: 32 | """Create and manage encryption keys.""" 33 | # pylint: disable=too-many-branches 34 | ctx.obj.assert_repo() 35 | repo: git.Repo = ctx.obj.repo 36 | gpm._create_git_subdir(repo) 37 | base = repo.git_dir 38 | _keydir, archivedir = _setup_keydir(base) 39 | cur_path = os.path.join(base, KEY_CURRENT) 40 | 41 | # check if previous password settings exist 42 | crypto = ctx.obj.get_crypto() 43 | if mode != "migrate": 44 | _check_abort_passwordbased(ctx, crypto) 45 | 46 | has_cur = os.path.exists(cur_path) 47 | if mode == "init": 48 | if has_cur: 49 | click.echo( 50 | "A key has already been set. " 51 | "To generate a new key use the '--new' option.", 52 | err=True, 53 | ) 54 | ctx.exit(1) 55 | key = gpcrypto.SecretBox.generate_key() 56 | with open(cur_path, "x") as f: 57 | f.write(key) 58 | click.echo("Key initialisation successful") 59 | elif mode == "new": 60 | if not has_cur: 61 | click.echo( 62 | "No active key found. " 63 | "To generate an initial key use the '--init' option.", 64 | err=True, 65 | ) 66 | ctx.exit(1) 67 | if archive: 68 | _archive_key(cur_path, archivedir) 69 | key = gpcrypto.SecretBox.generate_key() 70 | with open(cur_path, "w") as f: 71 | f.write(key) 72 | click.echo("Key replacement successful") 73 | elif mode == "migrate": 74 | # store old password-derived key 75 | if not isinstance(crypto, gpcrypto.PasswordSecretBox): 76 | click.echo("No password setting found to migrate.", err=True) 77 | ctx.exit(1) 78 | if has_cur: 79 | click.confirm( 80 | "A key has already been set. " 81 | "Replace it with password key?", 82 | abort=True, 83 | ) 84 | if archive: 85 | _archive_key(cur_path, archivedir) 86 | pwdkey = crypto._export_key() 87 | with open(cur_path, "w") as f: 88 | f.write(pwdkey) 89 | # comment out password and salt 90 | ctx.obj.comment_out_password_options() 91 | #click.echo("Migration successful", err=True) 92 | elif mode == "disable": 93 | if not has_cur: 94 | click.echo( 95 | "No active key found to disable.", 96 | err=True, 97 | ) 98 | ctx.exit(1) 99 | if archive: 100 | _archive_key(cur_path, archivedir) 101 | else: 102 | os.remove(cur_path) 103 | click.echo("Key disabled") 104 | else: 105 | raise ValueError("Unexpected value for mode") 106 | 107 | 108 | def _setup_keydir(base: str) -> Tuple[str, str]: 109 | keydir = os.path.join(base, KEY_DIR) 110 | if not os.path.exists(keydir): 111 | os.mkdir(keydir, mode=0o700) 112 | archivedir = os.path.join(base, KEY_ARCHIVE) 113 | if not os.path.exists(archivedir): 114 | os.mkdir(archivedir, mode=0o700) 115 | return (keydir, archivedir) 116 | 117 | 118 | def _archive_key(key_path: str, archivedir: str) -> None: 119 | """Archived keys are stored under incrementing ids.""" 120 | next_id = int(max(os.listdir(archivedir), key=_int_or_null, default=0)) + 1 121 | new_path = os.path.join(archivedir, str(next_id)) 122 | if os.path.exists(new_path): 123 | raise RuntimeError(f"Archived key already exists at {new_path}") 124 | os.rename(key_path, new_path) 125 | 126 | 127 | def _int_or_null(obj: str) -> int: 128 | try: 129 | return int(obj) 130 | except TypeError: 131 | return 0 132 | 133 | 134 | def _check_migrate_passwordbased( 135 | ctx: click.Context, 136 | crypto: gpcrypto.EncryptionProvider 137 | ) -> None: 138 | if isinstance(crypto, gpcrypto.PasswordSecretBox): 139 | ctx.invoke(manage_keys, mode="migrate", no_archive=False) 140 | 141 | 142 | def _check_abort_passwordbased( 143 | ctx: click.Context, 144 | crypto: gpcrypto.EncryptionProvider 145 | ) -> None: 146 | if isinstance(crypto, gpcrypto.PasswordSecretBox): 147 | click.echo( 148 | "A password is set in your config. " 149 | "Password-based encryption is no longer supported. " 150 | "To migrate run\n\n" 151 | " git-privacy keys --migrate-pwd\n", 152 | err=True, 153 | ) 154 | ctx.exit(1) 155 | 156 | 157 | def get_active_key(base: str) -> str: 158 | """Returns the encoded active key.""" 159 | path = os.path.join(base, KEY_CURRENT) 160 | try: 161 | with open(path) as f: 162 | key = f.read() 163 | except FileNotFoundError: 164 | key = "" 165 | return key 166 | 167 | 168 | def get_archived_keys(base: str) -> Iterator[str]: 169 | """Returns an iterator over all archived keys. Newest first.""" 170 | archivedir = os.path.join(base, KEY_ARCHIVE) 171 | if os.path.isdir(archivedir): 172 | keyfiles = os.listdir(archivedir) 173 | else: 174 | keyfiles = [] 175 | ids = filter( 176 | lambda x: x != 0, # sort out non-integer filenames 177 | map(_int_or_null, keyfiles), 178 | ) 179 | # newest key has highest id 180 | for key_id in sorted(ids, reverse=True): 181 | path = os.path.join(archivedir, str(key_id)) 182 | with open(path) as f: 183 | key = f.read() 184 | yield key 185 | -------------------------------------------------------------------------------- /gitprivacy/cli/pushcheck.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from typing import List, Optional, Set 4 | 5 | import click 6 | import git # type: ignore 7 | 8 | import gitprivacy.utils as utils 9 | 10 | 11 | NULL_HEX_SHA = '0000000000000000000000000000000000000000' 12 | TAG_PREFIX = "refs/tags/" 13 | 14 | 15 | @click.command('pre-push', hidden=True) 16 | @click.argument('remote_name', type=str) 17 | @click.argument('remote_location', type=str) 18 | @click.pass_context 19 | def check_push(ctx: click.Context, remote_name: str, 20 | remote_location: str) -> None: 21 | """Pre-push checks to be called by Git pre-push hook. 22 | 23 | Pushes are aborted if any commit that would be pushed contains dates that 24 | have not been redated according to the current redate pattern. 25 | In that case the user is shown a git-privacy statement to execute that 26 | redate. 27 | It is also lists if and which remote branches (other than the push 28 | target) already contain a version of those unredated commits and will thus 29 | diverge after a redate. 30 | """ 31 | del remote_location 32 | # read references from stdin (cf. githooks) 33 | lines = sys.stdin.readlines() 34 | 35 | if len(lines) == 0: 36 | # this might happen when pushing to a diverging remote without force 37 | # just let it pass and Git will complain for us 38 | # Note: In some cases a diverging remote will NOT cause this effect 39 | # hence we cannot rely on sorting out that case here completely. 40 | ctx.exit(0) 41 | 42 | for line in lines: 43 | check_push_line(ctx, remote_name, line) 44 | 45 | 46 | 47 | def check_push_line(ctx: click.Context, remote_name: str, line: str) -> None: 48 | # stdin format: 49 | # SP SP SP LF 50 | lref, lhash, _rref, rhash = line.strip().split(" ") 51 | if lref == "(delete)": 52 | assert lhash == NULL_HEX_SHA 53 | ctx.exit(0) # allow deletes in any case 54 | 55 | repo = ctx.obj.repo 56 | lref_commit = repo.commit(lhash) 57 | if rhash == NULL_HEX_SHA: # remote is empty 58 | refs = lhash # all commits reachalbe from lhash 59 | rref_commit: Optional[git.Commit] = None 60 | linear = True # empty remotes are always in line 61 | redate_base = "" 62 | else: 63 | # all reachable from lhash but not from rhash 64 | # if l and r diverge it's equivalent to lhash 65 | # if l is behind r it means refs is empty (all commits reachable) 66 | try: 67 | rref_commit = repo.commit(rhash) 68 | except ValueError: 69 | # rhash not found locally, i.e. is not part of local history 70 | linear = False 71 | else: 72 | linear = _is_parent_of(rref_commit, lref_commit) 73 | 74 | if not linear: 75 | # r diverges from l – push will fail unless forced 76 | # Note: We can only detect force pushes by checking the 77 | # arguments of the caller process (e.g., with psutil). 78 | # However this is a hack and requires additional dep. 79 | # In case of a non-force push displaying unredacted commits 80 | # distracts from the diverging issue and the check makes more 81 | # sense for the subsequent push (after merge or rebase) anyway. 82 | # Force pushes should by far be the rarer case. 83 | # Ergo: We warn the user and skip the check at the risk of 84 | # missing force pushes ith unredacted commits. 85 | click.echo("Detected diverging remote. " 86 | "Skip pre-push check for unredacted commits.", err=True) 87 | ctx.exit(0) 88 | else: 89 | refs = f"{rhash}..{lhash}" 90 | redate_base = utils.get_named_ref(rref_commit) 91 | 92 | # check for unredated commits 93 | redacter = ctx.obj.get_dateredacter() 94 | found_dirty = False 95 | for commit in repo.iter_commits(rev=refs): 96 | is_redacted = utils.is_already_redacted(redacter, commit) 97 | if not is_redacted: 98 | if not found_dirty: 99 | click.echo( 100 | "You tried to push commits with unredacted " 101 | "timestamps:", 102 | err=True, 103 | ) 104 | found_dirty = True 105 | click.echo(commit.hexsha, err=True) 106 | 107 | if not found_dirty: 108 | # all is redacted and fine – allow push 109 | ctx.exit(0) 110 | 111 | # Allow pushing tags that appear dirty but are not 112 | # because lref is already on the remote. 113 | # Rational: The dates are already public, ergo: no additional harm. 114 | if lref.startswith(TAG_PREFIX): 115 | if list_containing_branches(repo, lhash, f"{remote_name}/*"): 116 | # lref is already on this remote - allow 117 | ctx.exit(0) 118 | 119 | # Alert about dirty commits and abort push 120 | redate_param = f" {redate_base}" if redate_base else "" 121 | click.echo("\nTo redact and redate run:\n" 122 | f"\tgit-privacy redate{redate_param}", 123 | err=True) 124 | 125 | # get potential remote branches containing revs 126 | rbranches = list_containing_remote_branches(repo, refs) 127 | if rbranches: 128 | click.echo(click.wrap_text( 129 | "\nWARNING: Those commits seem to be part of the following" 130 | " remote branches." 131 | " After a redate your local history will diverge from them:\n" 132 | ), err=True) 133 | click.echo("\n".join(rbranches), err=True) 134 | click.echo(click.wrap_text( 135 | "\nNote: To push them without a redate pass the '--no-verify'" 136 | " option to git push." 137 | ), err=True) 138 | ctx.exit(1) 139 | 140 | 141 | def _is_parent_of(commit: git.Commit, child: git.Commit) -> bool: 142 | return commit in child.iter_parents() 143 | 144 | 145 | def list_containing_remote_branches(repo: git.Repo, revs: str) -> List[str]: 146 | """Identify remote branches that contain commits of the given rev.""" 147 | branches: Set[str] = set() 148 | commits_remote = list(repo.iter_commits([revs, "--remotes"])) 149 | for commit in commits_remote: 150 | branches.update(list_containing_branches(repo, commit.hexsha)) 151 | return list(branches) 152 | 153 | 154 | def list_containing_branches(repo: git.Repo, hexsha: str, 155 | pattern="*") -> List[str]: 156 | out = repo.git.branch(["-r", "--contains", hexsha, pattern]) 157 | return [b.strip() for b in out.splitlines()] 158 | -------------------------------------------------------------------------------- /gitprivacy/cli/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | def assertCommits(ctx: click.Context) -> None: 5 | """Assert that the current ref has commits.""" 6 | ctx.obj.assert_repo() 7 | head = ctx.obj.repo.head 8 | if not head.is_valid(): 9 | click.echo( 10 | f"fatal: your current branch '{head.ref.name}' " 11 | "does not have any commits yet", 12 | err=True 13 | ) 14 | ctx.exit(128) # Same exit-code as used by git 15 | -------------------------------------------------------------------------------- /gitprivacy/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | 5 | class DecryptionProvider(abc.ABC): 6 | """Abstract DecryptionProvider.""" 7 | @abc.abstractmethod 8 | def decrypt(self, data: str) -> Optional[str]: 9 | """Tries to decrypt Base64-encoded string and return plaintext or None.""" 10 | 11 | 12 | class EncryptionProvider(DecryptionProvider): 13 | """Abstract EncryptionProvider.""" 14 | @abc.abstractmethod 15 | def encrypt(self, data: str) -> str: 16 | """Encrypts data and returns an Base64-encoded string""" 17 | 18 | 19 | from .secretbox import SecretBox 20 | from .secretbox import MultiSecretBox 21 | from .secretbox import MultiSecretDecryptor 22 | from .passwordsecretbox import PasswordSecretBox 23 | -------------------------------------------------------------------------------- /gitprivacy/crypto/passwordsecretbox.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode, b64decode 2 | 3 | from nacl import pwhash, secret, utils # type: ignore 4 | 5 | from .secretbox import SecretBox 6 | 7 | 8 | class PasswordSecretBox(SecretBox): 9 | """NaCl SecretBox with secret derived from password.""" 10 | def __init__(self, salt: str, password: str) -> None: 11 | # pylint: disable=super-init-not-called 12 | enckey = self.derive_key(password, salt) 13 | self._box = secret.SecretBox(enckey) 14 | 15 | @staticmethod 16 | def derive_key(password: str, salt: str) -> bytes: 17 | return pwhash.scrypt.kdf( 18 | secret.SecretBox.KEY_SIZE, 19 | password.encode('utf-8'), 20 | b64decode(salt.encode('utf-8')), 21 | pwhash.SCRYPT_OPSLIMIT_INTERACTIVE, 22 | pwhash.SCRYPT_MEMLIMIT_INTERACTIVE, 23 | ) 24 | 25 | @staticmethod 26 | def generate_salt() -> str: 27 | """Generate and return base64-encoded salt.""" 28 | return b64encode(utils.random(pwhash.scrypt.SALTBYTES)).decode('utf-8') 29 | 30 | def _export_key(self) -> str: 31 | """Export Base64-encoded secret key.""" 32 | return b64encode(bytes(self._box)).decode('utf-8') 33 | -------------------------------------------------------------------------------- /gitprivacy/crypto/secretbox.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from typing import Iterable, Optional 3 | 4 | from nacl import secret, utils # type: ignore 5 | from nacl.encoding import Base64Encoder # type: ignore 6 | from nacl.exceptions import CryptoError # type: ignore 7 | 8 | from . import DecryptionProvider 9 | from . import EncryptionProvider 10 | 11 | 12 | class SecretBox(EncryptionProvider): 13 | """Wrapper for NaCl SecretBox.""" 14 | def __init__(self, key: str) -> None: 15 | """Initialise SecretBox from a Base64-encoded key.""" 16 | self._box = secret.SecretBox( 17 | key.encode('utf-8'), 18 | Base64Encoder, 19 | ) 20 | 21 | def encrypt(self, data: str) -> str: 22 | """Encrypts data and returns an Base64-encoded string""" 23 | return self._box.encrypt( 24 | str(data).encode('utf-8'), 25 | encoder=Base64Encoder 26 | ).decode('utf-8') 27 | 28 | def decrypt(self, data: str) -> Optional[str]: 29 | try: 30 | return self._box.decrypt( 31 | str(data).encode('utf-8'), 32 | encoder=Base64Encoder 33 | ).decode('utf-8') 34 | except CryptoError: 35 | return None 36 | 37 | @staticmethod 38 | def generate_key() -> str: 39 | """Generate and return base64-encoded key.""" 40 | key = utils.random(secret.SecretBox.KEY_SIZE) 41 | return b64encode(key).decode('utf-8') 42 | 43 | 44 | class MultiSecretDecryptor(DecryptionProvider): 45 | """Decryptor supporting multiple decryption keys.""" 46 | def __init__(self, keyarchive: Iterable[str]) -> None: 47 | """Initialise SecretBox from a Base64-encoded key.""" 48 | self.__archive = tuple( 49 | map(SecretBox, keyarchive) 50 | ) 51 | 52 | def decrypt(self, data: str) -> Optional[str]: 53 | """Attempt to decrypt data with any available key.""" 54 | for box in self.__archive: 55 | res = box.decrypt(data) 56 | if res is not None: 57 | return res 58 | return None 59 | 60 | 61 | class MultiSecretBox(MultiSecretDecryptor, SecretBox): 62 | """SecretBox supporting multiple decryption keys.""" 63 | def __init__(self, key: str, keyarchive: Iterable[str]) -> None: 64 | SecretBox.__init__(self, key) 65 | MultiSecretDecryptor.__init__(self, keyarchive) 66 | 67 | def decrypt(self, data: str) -> Optional[str]: 68 | """Attempt to decrypt data with any available key.""" 69 | res = SecretBox.decrypt(self, data) 70 | if res is not None: 71 | return res 72 | return MultiSecretDecryptor.decrypt(self, data) 73 | -------------------------------------------------------------------------------- /gitprivacy/dateredacter/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from datetime import datetime 3 | 4 | 5 | class DateRedacter(abc.ABC): 6 | """Abstract timestamp redater.""" 7 | 8 | @abc.abstractmethod 9 | def redact(self, timestamp: datetime) -> datetime: 10 | """Redact timestamp.""" 11 | 12 | 13 | from .reduce import ResolutionDateRedacter 14 | -------------------------------------------------------------------------------- /gitprivacy/dateredacter/reduce.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import re 3 | 4 | from . import DateRedacter 5 | 6 | 7 | class ResolutionDateRedacter(DateRedacter): 8 | """Resolution reducing timestamp redacter.""" 9 | def __init__(self, pattern="s", limit=None, mode="reduce"): 10 | self.mode = mode 11 | self.pattern = pattern 12 | self.limit = limit 13 | if limit: 14 | try: 15 | match = re.search('([0-9]+)-([0-9]+)', str(limit)) 16 | self.limit = (int(match.group(1)), int(match.group(2))) 17 | except AttributeError: 18 | raise ValueError("Unexpected syntax for limit.") 19 | 20 | def redact(self, timestamp: datetime) -> datetime: 21 | """Reduces timestamp precision for the parts specifed by the pattern using 22 | M: month, d: day, h: hour, m: minute, s: second. 23 | 24 | Example: A pattern of 's' sets the seconds to 0.""" 25 | 26 | if "M" in self.pattern: 27 | timestamp = timestamp.replace(month=1) 28 | if "d" in self.pattern: 29 | timestamp = timestamp.replace(day=1) 30 | if "h" in self.pattern: 31 | timestamp = timestamp.replace(hour=0) 32 | if "m" in self.pattern: 33 | timestamp = timestamp.replace(minute=0) 34 | if "s" in self.pattern: 35 | timestamp = timestamp.replace(second=0) 36 | timestamp = self._enforce_limit(timestamp) 37 | return timestamp 38 | 39 | def _enforce_limit(self, timestamp: datetime) -> datetime: 40 | if not self.limit: 41 | return timestamp 42 | start, end = self.limit 43 | if timestamp.hour < start: 44 | timestamp = timestamp.replace(hour=start, minute=0, second=0) 45 | if timestamp.hour >= end: 46 | timestamp = timestamp.replace(hour=end, minute=0, second=0) 47 | return timestamp 48 | -------------------------------------------------------------------------------- /gitprivacy/encoder/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import git # type: ignore 3 | 4 | from datetime import datetime 5 | from typing import Callable, Optional, Tuple, Union 6 | 7 | from ..dateredacter import DateRedacter 8 | 9 | 10 | class Encoder(abc.ABC): 11 | """Abstract commit encoder.""" 12 | @abc.abstractmethod 13 | def encode(self, commit: git.Commit) -> Tuple[datetime, datetime, str]: 14 | """Encode commit.""" 15 | 16 | 17 | class Decoder(abc.ABC): 18 | """Abstract commit decoder.""" 19 | @abc.abstractmethod 20 | def decode(self, commit: git.Commit) -> Tuple[Optional[datetime], 21 | Optional[datetime]]: 22 | """Decode commit.""" 23 | 24 | 25 | class BasicEncoder(Encoder): 26 | """Basic commit encoder that only inserts redacted dates.""" 27 | def __init__(self, redacter: DateRedacter) -> None: 28 | self.redacter = redacter 29 | 30 | def encode(self, commit: git.Commit) -> Tuple[datetime, datetime, str]: 31 | new_ad = self.redacter.redact(commit.authored_datetime) 32 | new_cd = self.redacter.redact(commit.committed_datetime) 33 | if (new_ad == commit.authored_datetime and 34 | new_cd == commit.committed_datetime): 35 | # already redacted – nothing to do 36 | return (new_ad, new_cd, "") 37 | msg_extra = self.get_message_extra(commit) 38 | new_msg = "" # signifies no change to old message 39 | if isinstance(msg_extra, str): 40 | if msg_extra: 41 | linesep = "\n" 42 | if commit.message.endswith(linesep): 43 | sep = linesep 44 | else: 45 | # make sure there is a blank line separating 46 | # orig message and the extra 47 | sep = linesep * 2 48 | new_msg = commit.message + sep + msg_extra 49 | elif callable(msg_extra): 50 | rpl_msg = msg_extra(commit.message) # pylint: disable=not-callable 51 | if rpl_msg != commit.message: 52 | new_msg = rpl_msg 53 | else: 54 | raise TypeError("Unexpected msg_extra type") 55 | return (new_ad, new_cd, new_msg) 56 | 57 | def get_message_extra(self, commit: git.Commit) -> Union[ 58 | str, 59 | Callable[[str], str] 60 | ]: 61 | # pylint: disable=no-self-use,unused-argument 62 | return "" 63 | 64 | 65 | class BasicDecoder(Decoder): 66 | """Basic commit decoder returning dates as in the commit metadata.""" 67 | def decode(self, commit: git.Commit) -> Tuple[Optional[datetime], 68 | Optional[datetime]]: 69 | return (commit.authored_datetime, 70 | commit.committed_datetime) 71 | 72 | 73 | from .msgembed import MessageEmbeddingEncoder, MessageEmbeddingDecoder 74 | -------------------------------------------------------------------------------- /gitprivacy/encoder/msgembed.py: -------------------------------------------------------------------------------- 1 | import git # type: ignore 2 | import re 3 | 4 | from datetime import datetime 5 | from typing import Callable, Optional, Tuple, Union 6 | 7 | from . import BasicEncoder, Decoder 8 | from .. import utils 9 | from ..crypto import DecryptionProvider, EncryptionProvider 10 | from ..dateredacter import DateRedacter 11 | 12 | 13 | MSG_TAG = "GitPrivacy: " 14 | TAG_REGEX = fr'^{MSG_TAG}(\S+)(?: (\S+))?' 15 | 16 | 17 | class MessageEmbeddingEncoder(BasicEncoder): 18 | def __init__(self, 19 | redacter: DateRedacter, 20 | crypto: EncryptionProvider) -> None: 21 | super().__init__(redacter) 22 | self.crypto = crypto 23 | 24 | 25 | def get_message_extra(self, commit: git.Commit) -> Union[ 26 | str, 27 | Callable[[str], str], 28 | ]: 29 | """Get date ciphertext addition to commit message.""" 30 | if not _contains_tag(commit): # keep prior tag if already present 31 | # create new tag 32 | a_date = _encrypt_for_msg(self.crypto, commit.authored_datetime) 33 | c_date = _encrypt_for_msg(self.crypto, commit.committed_datetime) 34 | return f"{MSG_TAG}{a_date} {c_date}" 35 | # update the committer date ciphertext 36 | ciphers = _extract_enc_dates(commit.message) 37 | assert ciphers is not None # we know it contains the tag 38 | ad_cipher, _cd_cipher = ciphers 39 | c_date = _encrypt_for_msg(self.crypto, commit.committed_datetime) 40 | new_extra = f"{MSG_TAG}{ad_cipher} {c_date}" 41 | return lambda msg: re.sub(TAG_REGEX, new_extra, msg, 42 | flags=re.MULTILINE) 43 | 44 | 45 | class MessageEmbeddingDecoder(Decoder): 46 | def __init__(self, crypto: DecryptionProvider) -> None: 47 | self.crypto = crypto 48 | 49 | def decode(self, commit: git.Commit) -> Tuple[Optional[datetime], 50 | Optional[datetime]]: 51 | return _decrypt_from_msg(self.crypto, commit.message) 52 | 53 | 54 | def _contains_tag(commit: git.Commit): 55 | return any([line.startswith(MSG_TAG) 56 | for line in commit.message.splitlines()]) 57 | 58 | 59 | def _extract_enc_dates(msg: str) -> Optional[Tuple[str, Optional[str]]]: 60 | """Extract encrypted dates from the commit message 61 | 62 | Returns either a combined cipher for author and committer date or one 63 | separate cipher each if present. 64 | """ 65 | for line in msg.splitlines(): 66 | # 2nd cipher is optional for backward compatability with 67 | # combined author and committer date ciphers 68 | match = re.search(TAG_REGEX, line) 69 | if match: 70 | ad_cipher, cd_cipher = match.groups() 71 | return (ad_cipher, cd_cipher) 72 | return None 73 | 74 | 75 | def _encrypt_for_msg(crypto: EncryptionProvider, date: datetime) -> str: 76 | """Returns ciphertext date.""" 77 | return crypto.encrypt(utils.dt2gitdate(date)) 78 | 79 | 80 | def _decrypt_from_msg(crypto: DecryptionProvider, message: str) -> Tuple[ 81 | Optional[datetime], 82 | Optional[datetime], 83 | ]: 84 | enc_dates = _extract_enc_dates(message) 85 | if crypto is None or enc_dates is None: 86 | return (None, None) 87 | enc_adate, enc_cdate = enc_dates 88 | raw_adate = crypto.decrypt(enc_adate) 89 | if enc_cdate: 90 | # use separate committer date 91 | raw_cdate = crypto.decrypt(enc_cdate) 92 | if raw_adate and ";" in raw_adate: 93 | # discard combined committer date for newer separate 94 | raw_adate, _ = raw_adate.split(";") 95 | elif raw_adate and not enc_cdate: 96 | # combined cipher compatability mode 97 | assert ";" in raw_adate 98 | raw_adate, raw_cdate = raw_adate.split(";") 99 | else: 100 | # no readable ciphertext to use 101 | assert not raw_adate and not enc_cdate 102 | raw_cdate = None 103 | 104 | a_date = None 105 | c_date = None 106 | if raw_adate: 107 | assert ";" not in raw_adate 108 | a_date = utils.gitdate2dt(raw_adate) 109 | if raw_cdate: 110 | c_date = utils.gitdate2dt(raw_cdate) 111 | return a_date, c_date 112 | -------------------------------------------------------------------------------- /gitprivacy/gitprivacy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | git privacy 4 | """ 5 | import click 6 | import git # type: ignore 7 | import os 8 | import shutil 9 | import stat 10 | import sys 11 | 12 | from datetime import datetime, timezone 13 | from pkg_resources import resource_stream, resource_string 14 | from typing import Optional, TextIO, Tuple 15 | 16 | 17 | from . import GIT_SUBDIR 18 | from . import crypto as crypt 19 | from .cli import email 20 | from .cli import keys 21 | from .cli import pushcheck 22 | from .cli.utils import assertCommits 23 | from .dateredacter import DateRedacter, ResolutionDateRedacter 24 | from .encoder import ( 25 | Encoder, BasicEncoder, MessageEmbeddingEncoder, 26 | Decoder, BasicDecoder, MessageEmbeddingDecoder, 27 | ) 28 | from .rewriter import AmendRewriter, FilterRepoRewriter 29 | from .utils import fmtdate 30 | 31 | 32 | class GitPrivacyConfig: 33 | SECTION = "privacy" 34 | 35 | def __init__(self, gitdir: str) -> None: 36 | self.gitdir = gitdir 37 | try: 38 | self.repo = git.Repo(gitdir, search_parent_directories=True) 39 | except git.InvalidGitRepositoryError as e: 40 | self.repo = None 41 | else: 42 | self.read_config() 43 | 44 | 45 | def read_config(self): 46 | self.assert_repo() 47 | with self.repo.config_reader() as config: 48 | self.mode = config.get_value(self.SECTION, 'mode', 'reduce') 49 | self.pattern = config.get_value(self.SECTION, 'pattern', '') 50 | self.limit = config.get_value(self.SECTION, 'limit', '') 51 | self.password = config.get_value(self.SECTION, 'password', '') 52 | self.salt = config.get_value(self.SECTION, 'salt', '') 53 | self.ignoreTimezone = bool(config.get_value( 54 | self.SECTION, 'ignoreTimezone', True)) 55 | self.replace = bool(config.get_value( 56 | self.SECTION, 'replacements', False)) 57 | 58 | def assert_repo(self): 59 | if not self.repo: 60 | raise click.UsageError("not a git repository: '{}'".format(self.gitdir)) 61 | 62 | def get_crypto(self) -> Optional[crypt.EncryptionProvider]: 63 | self.assert_repo() 64 | if self.password: 65 | if not self.salt: 66 | self.salt = crypt.PasswordSecretBox.generate_salt() 67 | self.write_config(salt=self.salt) 68 | return crypt.PasswordSecretBox(self.salt, str(self.password)) 69 | key = keys.get_active_key(self.repo.git_dir) 70 | archive = keys.get_archived_keys(self.repo.git_dir) 71 | if key: 72 | return crypt.MultiSecretBox(key=key, keyarchive=archive) 73 | return None 74 | 75 | def get_decrypto(self) -> Optional[crypt.DecryptionProvider]: 76 | self.assert_repo() 77 | # try to get a EncryptionProvider, then fallback to DecryptionProvider 78 | crypto = self.get_crypto() 79 | if crypto: 80 | return crypto 81 | archive = keys.get_archived_keys(self.repo.git_dir) 82 | return crypt.MultiSecretDecryptor(keyarchive=archive) 83 | 84 | def get_dateredacter(self) -> DateRedacter: 85 | self.assert_repo() 86 | if self.mode == "reduce" and self.pattern == '': 87 | raise click.ClickException(click.wrap_text( 88 | "Missing pattern configuration. Set a reduction pattern using\n" # noqa: E501 89 | "\n" 90 | f" git config {self.SECTION}.pattern \n" 91 | "\n" 92 | "The pattern is a comma separated list that may contain the " 93 | "following time unit identifiers: " 94 | "M: month, d: day, h: hour, m: minute, s: second.", 95 | preserve_paragraphs=True)) 96 | return ResolutionDateRedacter(self.pattern, self.limit, self.mode) 97 | 98 | def write_config(self, **kwargs): 99 | """Write config""" 100 | self.assert_repo() 101 | with self.repo.config_writer(config_level='repository') as writer: 102 | for key, value in kwargs.items(): 103 | writer.set_value(self.SECTION, key, value) 104 | 105 | def comment_out_password_options(self): 106 | self.assert_repo() 107 | with self.repo.config_writer() as config: 108 | if self.password: 109 | config.remove_option(self.SECTION, 'password') 110 | config.set_value(self.SECTION, "#password", self.password) 111 | self.password = "" 112 | if self.salt: 113 | config.remove_option(self.SECTION, 'salt') 114 | config.set_value(self.SECTION, "#salt", self.salt) 115 | self.salt = "" 116 | 117 | 118 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 119 | 120 | 121 | @click.group(context_settings=CONTEXT_SETTINGS) 122 | @click.option('--gitdir', default=os.getcwd, 123 | type=click.Path(exists=True, file_okay=False, dir_okay=True, 124 | readable=True), 125 | help="Path to your Git repsitory.") 126 | @click.pass_context 127 | def cli(ctx: click.Context, gitdir): 128 | ctx.obj = GitPrivacyConfig(gitdir) 129 | 130 | 131 | @cli.command('init') 132 | @click.option('-g', '--global', "globally", is_flag=True, 133 | help="Setup a global template instead.") 134 | @click.option('--timezone-change', type=click.Choice(("warn", "abort")), 135 | help=("Reaction strategy to detected time zone changes pre commit." 136 | " (default: warn)")) 137 | @click.pass_context 138 | def do_init(ctx: click.Context, globally: bool, 139 | timezone_change: Optional[str]) -> None: 140 | """Init git-privacy for this repository.""" 141 | ctx.obj.assert_repo() 142 | repo = ctx.obj.repo 143 | if globally: 144 | git_dir = get_template_dir(repo) 145 | else: 146 | git_dir = repo.git_dir 147 | copy_hook(git_dir, "post-commit") 148 | copy_hook(git_dir, "pre-commit") 149 | copy_hook(git_dir, "post-rewrite") 150 | copy_hook(git_dir, "pre-push") 151 | # only (over-)write settings if option is explicitly specified 152 | if timezone_change is not None: 153 | assert timezone_change in ("warn", "abort") 154 | ctx.obj.write_config(ignoreTimezone=(timezone_change == "warn")) 155 | 156 | 157 | def get_template_dir(repo: git.Repo) -> str: 158 | default_templatedir = os.path.join(os.path.expanduser("~"), 159 | ".git_template") 160 | with repo.config_reader(config_level="global") as config: 161 | templatedir = config.get_value("init", "templatedir", "") 162 | if templatedir: 163 | if os.path.isdir(templatedir): 164 | return templatedir # use existing non-default template 165 | if templatedir != default_templatedir: 166 | click.confirm("Template directory is currently set to " 167 | f"non-existing {templatedir}. " 168 | "Overwrite?", abort=True) 169 | else: 170 | # template set to default but folders are missing 171 | # recreate them in the following 172 | pass 173 | templatedir = default_templatedir 174 | os.mkdir(templatedir) 175 | os.mkdir(os.path.join(templatedir, "hooks")) 176 | with repo.config_writer(config_level="global") as config: 177 | config.set_value("init", "templatedir", templatedir) 178 | return templatedir 179 | 180 | 181 | def copy_hook(git_path: str, hook: str, ) -> None: 182 | hookdir = os.path.join(git_path, "hooks") 183 | if not os.path.exists(hookdir): 184 | os.mkdir(hookdir) 185 | hook_fn = os.path.join(hookdir, hook) 186 | try: 187 | dst = open(hook_fn, "xb") 188 | except FileExistsError: 189 | hook_txt = resource_string('gitprivacy.resources.hooks', hook).decode() 190 | with open(hook_fn, "r") as f: 191 | if f.read() == hook_txt: 192 | print(f"{hook} hook is already installed at {hook_fn}.") 193 | return 194 | print(f"A Git hook already exists at {hook_fn}", file=sys.stderr) 195 | print("\nRemove hook and rerun or add the following to the existing " 196 | f"hook:\n\n{hook_txt}") 197 | return 198 | else: 199 | with resource_stream('gitprivacy.resources.hooks', hook) as src, dst: 200 | shutil.copyfileobj(src, dst) # type: ignore 201 | os.chmod(hook_fn, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | 202 | stat.S_IROTH | stat.S_IXOTH) # mode 755 203 | print("Installed {} hook".format(hook)) 204 | 205 | 206 | @cli.command('log') 207 | @click.option('-r', '--revision-range', required=False, default='HEAD', 208 | help="Show only commits in the specified revision range.") 209 | @click.argument('paths', nargs=-1, type=click.Path(exists=True)) 210 | @click.pass_context 211 | def do_log(ctx: click.Context, revision_range: str, paths: click.Path): 212 | """Display a git-log-like history.""" 213 | assertCommits(ctx) 214 | repo = ctx.obj.repo 215 | crypto: crypt.DecryptionProvider = ctx.obj.get_decrypto() 216 | #keys._check_abort_passwordbased(ctx, crypto) 217 | if crypto: 218 | decoder: Decoder = MessageEmbeddingDecoder(crypto) 219 | else: 220 | decoder = BasicDecoder() 221 | commit_list = list(repo.iter_commits(rev=revision_range, paths=paths)) 222 | buf = list() 223 | for commit in commit_list: 224 | buf.append(click.style(f"commit {commit.hexsha}", fg='yellow')) 225 | a_date, c_date = decoder.decode(commit) 226 | if a_date: 227 | buf.append(f"Author: {commit.author.name} <{commit.author.email}>") # noqa: E501 228 | buf.append(click.style(f"Date: {fmtdate(commit.authored_datetime)}", # noqa: E501) 229 | fg='red')) 230 | buf.append(click.style(f"RealDate: {fmtdate(a_date)}", fg='green')) 231 | else: 232 | buf.append(f"Author: {commit.author.name} <{commit.author.email}>") 233 | buf.append(f"Date: {fmtdate(commit.authored_datetime)}") 234 | if c_date: 235 | buf.append(f"Commit: {commit.committer.name} <{commit.committer.email}>") # noqa: E501 236 | buf.append(click.style(f"Date: {fmtdate(commit.committed_datetime)}", # noqa: E501) 237 | fg='red')) 238 | buf.append(click.style(f"RealDate: {fmtdate(c_date)}", fg='green')) 239 | else: 240 | buf.append(f"Commit: {commit.committer.name} <{commit.committer.email}>") 241 | buf.append(f"Date: {fmtdate(commit.committed_datetime)}") 242 | buf.append(os.linesep + f" {commit.message}") 243 | click.echo_via_pager(os.linesep.join(buf)) 244 | 245 | 246 | def _is_cherrypick_finished(repo: git.Repo) -> bool: 247 | cherrypick_head = os.path.join(repo.git_dir, "CHERRY_PICK_HEAD") 248 | return os.path.exists(cherrypick_head) is False 249 | 250 | 251 | @cli.command('redate') 252 | @click.argument('startpoint', required=False, default='') 253 | @click.option('--only-head', is_flag=True, 254 | help="Redate only the current head.") 255 | @click.option('-f', '--force', is_flag=True, 256 | help="Force redate of commits.") 257 | @click.pass_context 258 | def do_redate(ctx: click.Context, startpoint: str, 259 | only_head: bool, force: bool): 260 | """Redact timestamps of existing commits.""" 261 | assertCommits(ctx) 262 | repo = ctx.obj.repo 263 | # check for in progress rewrites (cherry-picks) 264 | # newer versions of Git trigger post-commit hook during 265 | # a cherry-pick, which conflicts with the amend rewriter 266 | # and also might lead to unexpected redates. 267 | # Hence do not redate during cherry-picks. 268 | # Briefly wait for the cherry-pick to finish 269 | # otherwise abort redating with a warning. 270 | if not _is_cherrypick_finished(repo): 271 | click.echo( 272 | '\n' 273 | 'Warning: cherry-pick in progress. No redate possible.', 274 | err=True, 275 | ) 276 | ctx.exit(5) 277 | redacter = ctx.obj.get_dateredacter() 278 | crypto = ctx.obj.get_crypto() 279 | keys._check_migrate_passwordbased(ctx, crypto) 280 | if crypto: 281 | encoder: Encoder = MessageEmbeddingEncoder(redacter, crypto) 282 | else: 283 | encoder = BasicEncoder(redacter) 284 | 285 | if only_head: # use AmendRewriter to allow redates in dirty dirs 286 | amendrewriter = AmendRewriter(repo, encoder, ctx.obj.replace) 287 | if amendrewriter.is_already_active(): 288 | return # avoid cyclic invocation by post-commit hook 289 | amendrewriter.rewrite() 290 | return 291 | 292 | if repo.is_dirty(): 293 | click.echo(f"Cannot redate: You have unstaged changes.", err=True) 294 | ctx.exit(1) 295 | rewriter = FilterRepoRewriter(repo, encoder, ctx.obj.replace) 296 | single_commit = next(repo.head.commit.iter_parents(), None) is None 297 | try: 298 | if startpoint and not single_commit: 299 | if not repo.is_ancestor(startpoint, "HEAD"): 300 | click.echo("Startpoint not reachable from HEAD", err=True) 301 | ctx.exit(128) 302 | rev = f"{startpoint}..HEAD" 303 | else: 304 | rev = "HEAD" 305 | if startpoint: 306 | # Enforce validity of user-defined startpoint 307 | # to give proper feedback 308 | repo.commit(startpoint) 309 | commits = list(repo.iter_commits(rev)) 310 | except (git.GitCommandError, git.BadName): 311 | click.echo(f"bad revision '{startpoint}'", err=True) 312 | ctx.exit(128) 313 | if len(commits) == 0: 314 | click.echo(f"Found nothing to redate for '{rev}'", err=True) 315 | ctx.exit(128) 316 | remotes = repo.git.branch(["-r", "--contains", commits[-1].hexsha]) 317 | if remotes and not force: 318 | click.echo( 319 | "You are trying to redate commits contained in remote branches.\n" 320 | "Use '-f' to proceed if you are really sure.", 321 | err=True 322 | ) 323 | ctx.exit(3) 324 | # add commits in reversed order to rewriter startpoint first, HEAD last 325 | with click.progressbar(reversed(commits), label="Redating commits") as bar: 326 | for commit in bar: 327 | rewriter.update(commit) 328 | rewriter.finish() 329 | 330 | 331 | @cli.command('redate-rewrites') 332 | @click.pass_context 333 | def redate_rewrites(ctx: click.Context): 334 | """Redact committer timestamps of rewritten commits.""" 335 | assertCommits(ctx) 336 | repo = ctx.obj.repo 337 | rewrites_log_path = os.path.join(repo.git_dir, GIT_SUBDIR, "rewrites") 338 | if not os.path.exists(rewrites_log_path): 339 | click.echo("No pending rewrites to redact") 340 | ctx.exit(0) 341 | if repo.is_dirty(): 342 | click.echo(f"Cannot redate: You have unstaged changes.", err=True) 343 | ctx.exit(1) 344 | 345 | # determine commits to redate 346 | with open(rewrites_log_path, "r") as rwlog_fp: 347 | rwlog = list(map(_parse_post_rewrite_format, rwlog_fp)) 348 | olds_set = set(e[0] for e in rwlog) 349 | news = [e[1] for e in rwlog] 350 | news_set = set(news) 351 | pending_set = news_set.difference(olds_set) # ignore already rewritten news 352 | pending = [noid for noid in news if noid in pending_set] 353 | 354 | if len(pending) == 0: 355 | click.echo("No pending rewrites to redact") 356 | ctx.exit(0) 357 | 358 | redacter = ctx.obj.get_dateredacter() 359 | crypto = ctx.obj.get_crypto() 360 | keys._check_migrate_passwordbased(ctx, crypto) 361 | if crypto: 362 | encoder: Encoder = MessageEmbeddingEncoder(redacter, crypto) 363 | else: 364 | encoder = BasicEncoder(redacter) 365 | rewriter = FilterRepoRewriter(repo, encoder, ctx.obj.replace) 366 | 367 | commits = map(repo.commit, pending) # get Commit objects from hashes 368 | with click.progressbar(commits, label="Redating commits") as bar: 369 | for commit in bar: 370 | rewriter.update(commit) 371 | rewriter.finish() 372 | os.remove(rewrites_log_path) 373 | 374 | 375 | @cli.command('check', hidden=True) 376 | @click.pass_context 377 | def do_check(ctx: click.Context): 378 | """Pre-commit checks.""" 379 | ctx.obj.assert_repo() 380 | # check for setup up redaction patterns 381 | ctx.obj.get_dateredacter() # raises errors if pattern is missing 382 | # check for timezone changes 383 | tzchanged = ctx.invoke(check_timezone_changes) 384 | if tzchanged and not ctx.obj.ignoreTimezone: 385 | click.echo( 386 | '\n' 387 | 'abort commit (set "git config privacy.ignoreTimezone true"' 388 | ' to commit anyway)', 389 | err=True, 390 | ) 391 | ctx.exit(2) 392 | 393 | 394 | def _sanitize_config_email(email: str) -> str: 395 | return email.strip("'\"") 396 | 397 | 398 | @cli.command('tzcheck') 399 | @click.pass_context 400 | def check_timezone_changes(ctx: click.Context) -> bool: 401 | """Check for timezone change since last commit.""" 402 | ctx.obj.assert_repo() 403 | repo = ctx.obj.repo 404 | if not repo.head.is_valid(): 405 | return False # no previous commits 406 | with repo.config_reader() as cr: 407 | user_email = _sanitize_config_email( 408 | cr.get_value("user", "email", "") 409 | ) 410 | if not user_email: 411 | click.echo("No user email set.", err=True) 412 | ctx.exit(128) 413 | user_commits = repo.iter_commits( 414 | author=f"<{user_email}>", 415 | committer=f"<{user_email}>", 416 | ) 417 | last_commit = next(user_commits, None) 418 | if last_commit is None: 419 | click.echo("info: Skipping tzcheck - no previous commits with this email", err=True) 420 | return False # no previous commits by this user 421 | current_tz = datetime.now(timezone.utc).astimezone().tzinfo 422 | if last_commit.author.email == user_email: 423 | last_tz = last_commit.authored_datetime.tzinfo 424 | elif last_commit.committer.email == user_email: 425 | last_tz = last_commit.committed_datetime.tzinfo 426 | else: 427 | raise RuntimeError("Unexpected commit.") 428 | dummy_date = datetime.now() 429 | if (last_tz and current_tz and 430 | last_tz.utcoffset(dummy_date) != current_tz.utcoffset(dummy_date)): 431 | click.echo("Warning: Your timezone has changed since your last commit.", err=True) 432 | return True 433 | return False 434 | 435 | 436 | @cli.command('log-rewrites', hidden=True) 437 | @click.argument('rewrites', type=click.File("r"), default="-") 438 | @click.option('--type', type=click.Choice(("amend", "rebase"))) 439 | @click.pass_context 440 | def log_rewrites(ctx: click.Context, rewrites: TextIO, type: str): 441 | """Log rewrites for later redating of necessary.""" 442 | ctx.obj.assert_repo() 443 | # check if post-rewrites is triggered as result of AmendRewriter 444 | if AmendRewriter.is_already_active(): 445 | # no need to log own amends 446 | assert type == "amend" 447 | ctx.exit(0) 448 | # log rewrites 449 | repo: git.Repo = ctx.obj.repo 450 | redacter = ctx.obj.get_dateredacter() 451 | subdir = _create_git_subdir(repo) 452 | rewrites_log_path = os.path.join(subdir, "rewrites") 453 | found_dirty_dates = False 454 | with open(rewrites_log_path, "a") as log: 455 | for rewrite in rewrites: 456 | _oldhex, newhex, _ = _parse_post_rewrite_format(rewrite) 457 | if _has_dirtydate(repo, redacter, newhex): 458 | log.write(rewrite) 459 | found_dirty_dates = True 460 | # warn about dirty dates 461 | if found_dirty_dates: 462 | click.echo("""A rewrite may have inserted unredacted committer dates. 463 | To apply date redaction on these dates run 464 | 465 | git-privacy redate-rewrites 466 | 467 | Warning: This alters your Git history.""", err=True) 468 | 469 | 470 | def _create_git_subdir(repo: git.Repo) -> str: 471 | path = os.path.join(repo.git_dir, GIT_SUBDIR) 472 | if not os.path.exists(path): 473 | os.mkdir(path) 474 | return path 475 | 476 | 477 | def _parse_post_rewrite_format(line: str) -> Tuple[str, str, str]: 478 | # format given to post-rewrite hook (cf. githooks(5)): 479 | # SP [ SP ] LF 480 | vals = line.split(" ", maxsplit=2) 481 | n_vals = len(vals) 482 | assert n_vals in (2, 3), "Unexpected post-rewrite format" 483 | if n_vals == 2: 484 | # pad to length 3 485 | vals.append("") 486 | return (vals[0].strip(), vals[1].strip(), vals[2]) 487 | 488 | 489 | def _has_dirtydate(repo: git.Repo, redacter: DateRedacter, 490 | hexsha: str) -> bool: 491 | try: 492 | commit = repo.commit(hexsha) 493 | except ValueError: 494 | # commit no longer locatable 495 | # nothing to be dirty 496 | return False 497 | # check if commit is already loose, i.e. not part of any branch 498 | # (e.g., due to post-commit hook rewrites in the meantime) 499 | if not repo.git.branch("--contains", commit.hexsha): 500 | # do not warn about loose rewritten commits 501 | return False 502 | # check if commit date is already redacted 503 | new_cd = redacter.redact(commit.committed_datetime) 504 | if new_cd != commit.committed_datetime: 505 | return True # commit date is dirty 506 | return False 507 | 508 | 509 | cli.add_command(email.redact_email) 510 | cli.add_command(email.list_email) 511 | cli.add_command(keys.manage_keys) 512 | cli.add_command(pushcheck.check_push) 513 | -------------------------------------------------------------------------------- /gitprivacy/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMPRI-DEVOPS/git-privacy/f14c10fff2be830310c5ac94d3dfa6ac93bee185/gitprivacy/resources/__init__.py -------------------------------------------------------------------------------- /gitprivacy/resources/hooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMPRI-DEVOPS/git-privacy/f14c10fff2be830310c5ac94d3dfa6ac93bee185/gitprivacy/resources/hooks/__init__.py -------------------------------------------------------------------------------- /gitprivacy/resources/hooks/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | git-privacy redate --only-head 4 | -------------------------------------------------------------------------------- /gitprivacy/resources/hooks/post-rewrite: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # pass stdin rewrite info to git-privacy 4 | git-privacy log-rewrites --type $1 5 | -------------------------------------------------------------------------------- /gitprivacy/resources/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | git-privacy check 4 | -------------------------------------------------------------------------------- /gitprivacy/resources/hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | git-privacy pre-push $@ 4 | -------------------------------------------------------------------------------- /gitprivacy/rewriter/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import git # type: ignore 3 | 4 | from ..encoder import Encoder 5 | 6 | 7 | class Rewriter(abc.ABC): 8 | """Abstract Git history Rewriter.""" 9 | 10 | def __init__(self, repo: git.Repo, encoder: Encoder, 11 | replace: bool = False) -> None: 12 | self.repo = repo 13 | self.encoder = encoder 14 | self.replace = replace 15 | 16 | 17 | from .amendrewriter import AmendRewriter 18 | from .filterrewriter import FilterRepoRewriter 19 | -------------------------------------------------------------------------------- /gitprivacy/rewriter/amendrewriter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from . import Rewriter 5 | from ..utils import fmtdate 6 | 7 | 8 | class AmendRewriter(Rewriter): 9 | """Redates commits using git commit --amend.""" 10 | 11 | def rewrite(self) -> None: 12 | commit = self.repo.commit("HEAD") 13 | a_redacted, c_redacted, new_msg = self.encoder.encode(commit) 14 | cmd = [ 15 | "git", "commit", "--amend", "--allow-empty", "--quiet", 16 | # skip repeated pre-commit hook to avoid gitpython locale issues 17 | "--no-verify", 18 | f"--date=\"{fmtdate(a_redacted)}\"", 19 | ] 20 | if new_msg: 21 | cmd.append(f"--message={new_msg}") 22 | else: 23 | cmd.append("--no-edit") 24 | res, stdout, stderr = self.repo.git.execute( 25 | command=cmd, 26 | env=dict( 27 | GIT_COMMITTER_DATE=fmtdate(c_redacted), 28 | GITPRIVACY_ACTIVE="yes", 29 | ), 30 | with_extended_output=True, 31 | ) 32 | # forward outputs to stdout/stderr 33 | # Note: This indirection is necessary since git.execute does not allow 34 | # for passing stdout/stderr directly 35 | if stdout: 36 | print(stdout) 37 | if stderr: 38 | print(stderr, file=sys.stderr) 39 | 40 | # map replacement 41 | if self.replace: 42 | new_commit = self.repo.head.commit 43 | assert commit.hexsha != new_commit.hexsha 44 | res, _, err = self.repo.git.replace( 45 | commit.hexsha, 46 | new_commit.hexsha, 47 | with_extended_output=True, 48 | ) 49 | if res != 0: 50 | raise RuntimeError(err) 51 | 52 | 53 | @staticmethod 54 | def is_already_active() -> bool: 55 | return os.getenv("GITPRIVACY_ACTIVE") == "yes" 56 | -------------------------------------------------------------------------------- /gitprivacy/rewriter/filterrewriter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bulk rewriting of Git history using git-filter-repo 3 | """ 4 | import git # type: ignore 5 | import git_filter_repo as fr # type: ignore 6 | 7 | from typing import List, Set 8 | 9 | from . import Rewriter 10 | from .. import utils 11 | from ..encoder import Encoder 12 | 13 | 14 | class FilterRepoRewriter(Rewriter): 15 | """Redates commits using git-filter-repo.""" 16 | 17 | def __init__(self, repo: git.Repo, encoder: Encoder, 18 | replace: bool = False) -> None: 19 | super().__init__(repo, encoder, replace) 20 | self.commits_to_rewrite: List[git.Commit] = [] 21 | self.commits_oid_set: Set[str] = set() 22 | self.with_initial_commit = False 23 | 24 | 25 | def update(self, commit: git.Commit) -> None: 26 | if not commit.parents: 27 | self.with_initial_commit = True 28 | self.commits_to_rewrite.append(commit) 29 | self.commits_oid_set.add(commit.hexsha) 30 | 31 | def _rewrite(self, commit: fr.Commit, _metadata) -> None: 32 | hexid = commit.original_id.decode() 33 | if hexid not in self.commits_oid_set: 34 | # do nothing 35 | return 36 | g_commit = self.repo.commit(hexid) # get pygit Commit object 37 | a_redacted, c_redacted, new_msg = self.encoder.encode(g_commit) 38 | commit.author_date = utils.dt2gitdate(a_redacted).encode() 39 | commit.committer_date = utils.dt2gitdate(c_redacted).encode() 40 | if new_msg: 41 | commit.message = new_msg.encode() 42 | 43 | 44 | def finish(self) -> None: 45 | if not self.commits_to_rewrite: 46 | return # nothing to do 47 | if self.replace: 48 | replace_opt = "update-or-add" 49 | else: 50 | replace_opt = "update-no-add" 51 | # Use reference based names instead of OIDs. 52 | # This avoid Git's object name warning and 53 | # otherwise filter-repo fails to replace the objects. 54 | def rev_name(commit: git.Commit) -> str: 55 | return commit.name_rev.split()[1] 56 | first = self.commits_to_rewrite[0] 57 | last = self.commits_to_rewrite[-1] 58 | assert first == last or first in last.iter_parents(), "Wrong commit order" 59 | first_rev = rev_name(first) 60 | last_rev = rev_name(last) 61 | if self.with_initial_commit: 62 | refs = last_rev 63 | else: 64 | refs = f"{first_rev}^..{last_rev}" # ^ to include 'first' in the range 65 | args = fr.FilteringOptions.parse_args([ 66 | '--source', self.repo.git_dir, 67 | '--force', 68 | '--quiet', 69 | '--preserve-commit-encoding', 70 | '--replace-refs', replace_opt, 71 | '--refs', refs, 72 | ]) 73 | rfilter = fr.RepoFilter(args, commit_callback=self._rewrite) 74 | rfilter.run() 75 | -------------------------------------------------------------------------------- /gitprivacy/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import git # type: ignore 4 | 5 | import gitprivacy.dateredacter as dateredacter 6 | 7 | 8 | DATE_FMT = "%a %b %d %H:%M:%S %Y %z" 9 | 10 | 11 | def fmtdate(timestamp: datetime) -> str: 12 | return timestamp.strftime(DATE_FMT) 13 | 14 | 15 | def dt2gitdate(d: datetime) -> str: 16 | """Returns a UTC Posix timestamp with timezone information""" 17 | utc_sec = int(d.timestamp()) 18 | tz = d.strftime("%z") 19 | return f"{utc_sec} {tz}" 20 | 21 | 22 | def gitdate2dt(string: str) -> datetime: 23 | """Takes a UTC Posix timestamp with timezone information""" 24 | seconds, tz = string.split() 25 | return datetime.fromtimestamp( 26 | int(seconds), 27 | datetime.strptime(tz, "%z").tzinfo, 28 | ) 29 | 30 | 31 | def is_already_redacted(redacter: dateredacter.DateRedacter, 32 | commit: git.Commit) -> bool: 33 | """Check if the timestamps are already redacted.""" 34 | adate = commit.authored_datetime 35 | cdate = commit.committed_datetime 36 | new_ad = redacter.redact(adate) 37 | new_cd = redacter.redact(cdate) 38 | if new_ad == adate and new_cd == cdate: 39 | return True 40 | return False 41 | 42 | 43 | def get_named_ref(commit: git.Commit) -> str: 44 | """Get a user-friendly named ref for the commit.""" 45 | _hexsha, name = commit.name_rev.split(" ") 46 | return remove_prefix(name, "remotes/") 47 | 48 | 49 | def remove_prefix(string: str, prefix: str) -> str: 50 | if string.startswith(prefix): 51 | return string[len(prefix):] 52 | return string 53 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Testing 2 | pytest 3 | pytest-cov 4 | 5 | # Linter, etc 6 | pylint 7 | 8 | # Building releases 9 | setuptools 10 | wheel 11 | twine 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=7 2 | gitpython 3 | git-filter-repo>=2.27 4 | pynacl 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | with open('README.md') as f: 5 | README = f.read() 6 | 7 | setup( 8 | name='gitprivacy', 9 | version='2.3.0', 10 | description='Git wrapper redacting author and committer dates.', 11 | long_description=README, 12 | long_description_content_type="text/markdown", 13 | keywords=["git", "privacy", "timestamps"], 14 | maintainer='Christian Burkert', 15 | maintainer_email='gitprivacy@cburkert.de', 16 | url='https://github.com/EMPRI-DEVOPS/git-privacy', 17 | license="BSD", 18 | packages=find_packages(exclude=('tests', 'docs')), 19 | include_package_data=True, 20 | python_requires='>=3.6', 21 | install_requires=[ 22 | 'click>=7', 23 | 'gitpython', 24 | 'git-filter-repo>=2.27', 25 | 'pynacl', 26 | ], 27 | entry_points={ 28 | 'console_scripts': [ 29 | 'git-privacy = gitprivacy.gitprivacy:cli' 30 | ] 31 | }, 32 | classifiers=[ 33 | "Topic :: Software Development :: Version Control :: Git", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.6", 37 | "Programming Language :: Python :: 3.7", 38 | "Programming Language :: Python :: 3.8", 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "License :: OSI Approved :: BSD License", 42 | "Operating System :: OS Independent", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMPRI-DEVOPS/git-privacy/f14c10fff2be830310c5ac94d3dfa6ac93bee185/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/commit_cipher_combined: -------------------------------------------------------------------------------- 1 | blob 2 | mark :1 3 | data 0 4 | 5 | reset refs/heads/master 6 | commit refs/heads/master 7 | mark :2 8 | author Christian Burkert 1593417600 +0200 9 | committer Christian Burkert 1593417600 +0200 10 | data 116 11 | x 12 | 13 | GitPrivacy: d74YVg4JLrfHdGWgMaNunpyiG8oUw2V5gHEv539B7nFkExiGn0tSH/McUDGEpKdTvWAIUW4m38qCu7IbWLeBwkB4U143v9+yFQ== 14 | M 100644 :1 x 15 | 16 | -------------------------------------------------------------------------------- /tests/data/commit_cipher_dedicated: -------------------------------------------------------------------------------- 1 | blob 2 | mark :1 3 | data 0 4 | 5 | reset refs/heads/master 6 | commit refs/heads/master 7 | mark :2 8 | author Christian Burkert 1593421200 +0200 9 | committer Christian Burkert 1593421200 +0200 10 | data 169 11 | y 12 | 13 | GitPrivacy: cnBzoPL8dPq0yD5CkNBWjvUD158y3kuv5FbuDnn3vbbswMrQBxl2YduYGAR4F5Jcc4KQtuem0+4= GBwlds2WfblRTi7AqccHgYqyaBrr1SyCHWR7XFnzhA8aaz2sIwrrspEGwJupKNiYneTJG3x5TZU= 14 | M 100644 :1 y 15 | 16 | -------------------------------------------------------------------------------- /tests/data/commit_cipher_diffpwds: -------------------------------------------------------------------------------- 1 | blob 2 | mark :1 3 | data 0 4 | 5 | reset refs/heads/master 6 | commit refs/heads/master 7 | mark :2 8 | author Christian Burkert 1593442800 +0200 9 | committer Christian Burkert 1593442800 +0200 10 | data 169 11 | x 12 | 13 | GitPrivacy: ZpLJCHsnr0ctnhMZd2xtt9Utm/HW90Cjgg0wYuwLfYLcm9p2RtL0pWaWelfNPKW/G9gQNOhRfjI= hgbTXcR/4Sb4J0+w6jDiSe66EKp8Y2M3uId889rsj39jy1IJ0DRkm8wWaaNmXE4ykomsEjKnUEg= 14 | M 100644 :1 x 15 | 16 | -------------------------------------------------------------------------------- /tests/data/commit_cipher_mixed: -------------------------------------------------------------------------------- 1 | blob 2 | mark :1 3 | data 0 4 | 5 | reset refs/heads/master 6 | commit refs/heads/master 7 | mark :2 8 | author Christian Burkert 1593417600 +0200 9 | committer Christian Burkert 1593421200 +0200 10 | data 193 11 | x 12 | 13 | GitPrivacy: d74YVg4JLrfHdGWgMaNunpyiG8oUw2V5gHEv539B7nFkExiGn0tSH/McUDGEpKdTvWAIUW4m38qCu7IbWLeBwkB4U143v9+yFQ== N2RPOEaZz50e0yJrP2uUuOe3FAtUkvwZ71icGKUGPgcuJiKjDSmGZbFOEQIgIoNUO//rtYUa4zo= 14 | M 100644 :1 x 15 | 16 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from nacl.exceptions import CryptoError 3 | 4 | from gitprivacy.crypto import (PasswordSecretBox, SecretBox, MultiSecretBox, 5 | MultiSecretDecryptor) 6 | 7 | 8 | class CryptoTestCase(unittest.TestCase): 9 | def test_roundtrip(self): 10 | salt = PasswordSecretBox.generate_salt() 11 | c = PasswordSecretBox(salt, "passw0rd") 12 | enc = c.encrypt("foobar") 13 | self.assertEqual(c.decrypt(enc), "foobar") 14 | # test keyfile interop 15 | mbox = MultiSecretBox(key=c._export_key(), keyarchive=[]) 16 | self.assertEqual(mbox.decrypt(enc), "foobar") 17 | enc2 = mbox.encrypt("foobar") 18 | self.assertEqual(mbox.decrypt(enc2), "foobar") 19 | 20 | def test_wrongpwd(self): 21 | salt = PasswordSecretBox.generate_salt() 22 | c = PasswordSecretBox(salt, "passw0rd") 23 | c2 = PasswordSecretBox(salt, "password") 24 | enc = c.encrypt("foobar") 25 | self.assertEqual(c2.decrypt(enc), None) 26 | 27 | def test_keyarchive(self): 28 | key = SecretBox.generate_key() 29 | box = SecretBox(key=key) 30 | enc = box.encrypt("hello") 31 | self.assertEqual(box.decrypt(enc), "hello") 32 | mdec = MultiSecretDecryptor(keyarchive=[key]) 33 | self.assertEqual(mdec.decrypt(enc), "hello") 34 | # different key in archive 35 | key2 = SecretBox.generate_key() 36 | mdec2 = MultiSecretDecryptor(keyarchive=[key2]) 37 | self.assertEqual(mdec2.decrypt(enc), None) 38 | # both keys in archive 39 | mdec3 = MultiSecretDecryptor(keyarchive=[key2, key]) 40 | self.assertEqual(mdec3.decrypt(enc), "hello") 41 | -------------------------------------------------------------------------------- /tests/test_gitprivacy.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name,too-many-public-methods,line-too-long 2 | import copy 3 | import git # type: ignore 4 | import locale 5 | import os 6 | import pathlib 7 | import time 8 | import unittest 9 | 10 | from click.testing import CliRunner 11 | from datetime import datetime, timedelta, timezone 12 | from typing import Dict, Optional 13 | 14 | from gitprivacy.gitprivacy import cli, GitPrivacyConfig 15 | import gitprivacy.utils as utils 16 | 17 | 18 | # make sure no non-local configs are used 19 | HOME = ".home" 20 | NO_GLOBAL_CONF_ENV = dict( 21 | HOME=HOME, # ignore global config 22 | XDG_CONFIG_HOME="", 23 | GIT_CONFIG_NOSYSTEM="yes", # ignore system config 24 | ) 25 | 26 | 27 | class TestGitPrivacy(unittest.TestCase): 28 | def setUp(self) -> None: 29 | os.environ.update(NO_GLOBAL_CONF_ENV) 30 | self.home = HOME # only used for templates 31 | self.runner = CliRunner() 32 | # Prevent gitpython from forcing locales to ascii 33 | os.environ.update(self.getLang()) 34 | 35 | @staticmethod 36 | def getLang() -> Dict: 37 | lc, code = locale.getlocale() 38 | if lc and code: 39 | lc_str = f"{lc}.{code}" 40 | else: 41 | lc_str = "C.UTF-8" 42 | return dict(LANG=lc_str, LC_ALL=lc_str) 43 | 44 | def setUpRepo(self) -> None: 45 | self.repo = git.Repo.init() 46 | self.git = self.repo.git 47 | self.configGit(self.git) 48 | 49 | @staticmethod 50 | def configGit(gitwrap: git.Git) -> None: 51 | # set user info 52 | gitwrap.config(["user.name", "John Doe"]) 53 | gitwrap.config(["user.email", "jdoe@example.com"]) 54 | # Prevent locale issue when git-privacy is called from hooks 55 | gitwrap.update_environment(**TestGitPrivacy.getLang()) 56 | 57 | def setUpRemote(self, name="origin") -> git.Remote: 58 | r = git.Repo.init(f"remote_{name}", mkdir=True, bare=True) 59 | return self.repo.create_remote(name, r.working_dir) 60 | 61 | def setConfig(self) -> None: 62 | self.git.config(["privacy.pattern", "m,s"]) 63 | 64 | def addCommit(self, filename: str, repo: Optional[git.Repo] = None) -> git.Commit: 65 | if not repo: 66 | repo = self.repo 67 | oldcwd = os.getcwd() 68 | os.chdir(repo.working_dir) 69 | with open(filename, "w") as f: 70 | f.write(filename) 71 | repo.git.add(filename) 72 | res, _stdout, stderr = repo.git.commit( 73 | f"-m {filename}", 74 | with_extended_output=True, 75 | ) 76 | if res != 0: 77 | raise RuntimeError("Commit failed %s" % stderr) 78 | os.chdir(oldcwd) 79 | # make sure there are no rewrites logged during normal commits 80 | self.assertNotIn("redate-rewrites", stderr) 81 | # return a copy to avoid errors caused by the fazy loading of the 82 | # Commit object which in combination with git-filter-repo's eager 83 | # pruning results in failed lookups of no longer existing hashes 84 | return copy.copy(self.repo.head.commit) 85 | 86 | def invoke(self, args): 87 | return self.runner.invoke(cli, args=args) 88 | 89 | def test_nogit(self): 90 | with self.runner.isolated_filesystem(): 91 | result = self.invoke('log') 92 | self.assertEqual(result.exit_code, 2) 93 | 94 | def test_logwithemptygit(self): 95 | with self.runner.isolated_filesystem(): 96 | self.setUpRepo() 97 | result = self.invoke('log') 98 | self.assertEqual(result.exit_code, 128) 99 | 100 | def test_log(self): 101 | with self.runner.isolated_filesystem(): 102 | self.setUpRepo() 103 | self.addCommit("a") 104 | result = self.invoke('log') 105 | self.assertEqual(result.exit_code, 0) 106 | self.assertTrue(result.output.startswith("commit")) 107 | self.assertEqual(result.output.count("commit"), 1) 108 | self.addCommit("b") 109 | result = self.invoke('log') 110 | self.assertEqual(result.exit_code, 0) 111 | self.assertEqual(result.output.count("commit"), 2) 112 | result = self.invoke('log a') 113 | self.assertEqual(result.exit_code, 0) 114 | self.assertEqual(result.output.count("commit"), 1) 115 | result = self.invoke('log -r HEAD~1..HEAD') 116 | self.assertEqual(result.exit_code, 0) 117 | self.assertEqual(result.output.count("commit"), 1) 118 | result = self.invoke('log -r HEAD~1..HEAD -- a') 119 | self.assertEqual(result.exit_code, 0) 120 | self.assertEqual(result.output.count("commit"), 0) 121 | result = self.invoke('log x') 122 | self.assertEqual(result.exit_code, 2) 123 | 124 | def test_redateempty(self): 125 | with self.runner.isolated_filesystem(): 126 | self.setUpRepo() 127 | result = self.invoke('redate') 128 | self.assertEqual(result.exit_code, 128) 129 | 130 | def test_redatenoconfig(self): 131 | with self.runner.isolated_filesystem(): 132 | self.setUpRepo() 133 | self.addCommit("a") 134 | result = self.invoke('redate') 135 | self.assertIn("Error: Missing pattern configuration.", result.output) 136 | self.assertEqual(result.exit_code, 1) 137 | 138 | def test_redate(self): 139 | with self.runner.isolated_filesystem(): 140 | self.setUpRepo() 141 | self.setConfig() 142 | a = self.addCommit("a") 143 | result = self.invoke('redate') 144 | self.assertEqual(result.exit_code, 0) 145 | ar = self.repo.head.commit 146 | self.assertNotEqual(a, ar) 147 | self.assertNotEqual(a.authored_date, ar.authored_date) 148 | self.assertEqual(ar.authored_datetime, 149 | a.authored_datetime.replace(minute=0, second=0)) 150 | 151 | def test_redatemultiple(self): 152 | with self.runner.isolated_filesystem(): 153 | self.setUpRepo() 154 | self.setConfig() 155 | a = self.addCommit("a") 156 | b = self.addCommit("b") 157 | result = self.invoke('redate') 158 | self.assertEqual(result.exit_code, 0) 159 | ar = self.repo.commit("HEAD^") 160 | br = self.repo.commit("HEAD") 161 | self.assertNotEqual(a, ar) 162 | self.assertNotEqual(b, br) 163 | self.assertNotEqual(a.authored_date, ar.authored_date) 164 | self.assertNotEqual(b.authored_date, br.authored_date) 165 | 166 | def test_redatehead(self): 167 | with self.runner.isolated_filesystem(): 168 | self.setUpRepo() 169 | self.setConfig() 170 | a = self.addCommit("a") 171 | b = self.addCommit("b") 172 | result = self.invoke('redate --only-head') 173 | self.assertEqual(result.exit_code, 0) 174 | ar = self.repo.commit("HEAD^") 175 | br = self.repo.commit("HEAD") 176 | self.assertEqual(a, ar) 177 | self.assertNotEqual(b, br) 178 | self.assertEqual(a.authored_date, ar.authored_date) 179 | self.assertNotEqual(b.authored_date, br.authored_date) 180 | 181 | def test_redateheadsinglecommit(self): 182 | with self.runner.isolated_filesystem(): 183 | self.setUpRepo() 184 | self.setConfig() 185 | a = self.addCommit("a") 186 | result = self.invoke('redate --only-head') 187 | self.assertEqual(result.exit_code, 0) 188 | ar = self.repo.commit("HEAD") 189 | self.assertNotEqual(a, ar) 190 | self.assertNotEqual(a.authored_date, ar.authored_date) 191 | 192 | def test_redateheadwithunstagedchanges(self): 193 | with self.runner.isolated_filesystem(): 194 | self.setUpRepo() 195 | self.setConfig() 196 | a = self.addCommit("a") 197 | with open("a", "w") as f: 198 | f.write("unstagedchange") 199 | # redate should fail on dirty WD 200 | result = self.invoke('redate') 201 | self.assertEqual(result.exit_code, 1) 202 | # head-only redating should work 203 | result = self.invoke('redate --only-head') 204 | self.assertEqual(result.exception, None) 205 | self.assertEqual(result.exit_code, 0) 206 | ar = self.repo.commit("HEAD") 207 | self.assertNotEqual(a, ar) 208 | self.assertNotEqual(a.authored_date, ar.authored_date) 209 | 210 | def test_redateheadempty(self): 211 | with self.runner.isolated_filesystem(): 212 | self.setUpRepo() 213 | self.setConfig() 214 | self.git.commit(["--allow-empty", "-m foo"]) 215 | a = self.repo.head.commit 216 | result = self.invoke('redate --only-head') 217 | self.assertEqual(result.exit_code, 0) 218 | ar = self.repo.commit("HEAD") 219 | self.assertNotEqual(a, ar) 220 | self.assertNotEqual(a.authored_date, ar.authored_date) 221 | 222 | def test_redatefromstartpoint(self): 223 | with self.runner.isolated_filesystem(): 224 | self.setUpRepo() 225 | self.setConfig() 226 | a = self.addCommit("a") 227 | self.git.checkout(["-b", "abranch"]) 228 | b = self.addCommit("b") 229 | c = self.addCommit("c") 230 | result = self.invoke('redate master') 231 | self.assertEqual(result.exit_code, 0) 232 | ar = self.repo.commit("HEAD~2") 233 | br = self.repo.commit("HEAD~1") 234 | cr = self.repo.commit("HEAD") 235 | self.assertEqual(a, ar) 236 | self.assertNotEqual(b, br) 237 | self.assertNotEqual(c, cr) 238 | 239 | def test_redatewrongstartpoint(self): 240 | with self.runner.isolated_filesystem(): 241 | self.setUpRepo() 242 | self.setConfig() 243 | a = self.addCommit("a") 244 | result = self.invoke('redate abc') 245 | self.assertEqual(result.exit_code, 128) 246 | 247 | def test_redatestartpointhead(self): 248 | with self.runner.isolated_filesystem(): 249 | self.setUpRepo() 250 | self.setConfig() 251 | a = self.addCommit("a") 252 | b = self.addCommit("b") 253 | result = self.invoke('redate HEAD') 254 | self.assertEqual(result.exit_code, 128) 255 | 256 | def test_redatewithremote(self): 257 | with self.runner.isolated_filesystem(): 258 | self.setUpRepo() 259 | remote = self.setUpRemote() 260 | self.setConfig() 261 | a = self.addCommit("a") 262 | remote.push(self.repo.active_branch, set_upstream=True) 263 | result = self.invoke('redate') 264 | self.assertEqual(result.exit_code, 3) 265 | result = self.invoke('redate -f') 266 | self.assertEqual(result.exit_code, 0) 267 | remote.push(force=True) 268 | b = self.addCommit("b") 269 | c = self.addCommit("c") 270 | result = self.invoke('redate') 271 | self.assertEqual(result.exit_code, 3) 272 | result = self.invoke('redate HEAD~2') 273 | self.assertEqual(result.exit_code, 0) 274 | 275 | def test_init(self): 276 | with self.runner.isolated_filesystem(): 277 | self.setUpRepo() 278 | self.setConfig() 279 | result = self.invoke('init') 280 | self.assertEqual(result.exit_code, 0) 281 | self.assertEqual(result.output, os.linesep.join( 282 | f"Installed {hook} hook" 283 | for hook in ["post-commit", "pre-commit", "post-rewrite", 284 | "pre-push"] 285 | ) + os.linesep) 286 | self.assertTrue(os.access(os.path.join(".git", "hooks", "post-commit"), 287 | os.R_OK | os.X_OK)) 288 | self.assertTrue(os.access(os.path.join(".git", "hooks", "pre-commit"), 289 | os.F_OK)) 290 | a = self.addCommit("a") # gitpython already returns the rewritten commit 291 | self.assertEqual(a.authored_datetime, 292 | a.authored_datetime.replace(minute=0, second=0)) 293 | 294 | def test_initwithcheck(self): 295 | with self.runner.isolated_filesystem(): 296 | self.setUpRepo() 297 | self.setConfig() 298 | result = self.invoke('init --timezone-change=abort') 299 | self.assertEqual(result.exit_code, 0) 300 | self.assertEqual(result.output, os.linesep.join( 301 | f"Installed {hook} hook" 302 | for hook in ["post-commit", "pre-commit", "post-rewrite", 303 | "pre-push"] 304 | ) + os.linesep) 305 | self.assertTrue(os.access(os.path.join(".git", "hooks", "post-commit"), 306 | os.R_OK | os.X_OK)) 307 | self.assertTrue(os.access(os.path.join(".git", "hooks", "pre-commit"), 308 | os.R_OK | os.X_OK)) 309 | 310 | def test_checkempty(self): 311 | with self.runner.isolated_filesystem(): 312 | self.setUpRepo() 313 | self.setConfig() 314 | result = self.invoke('check') 315 | self.assertEqual(result.exit_code, 0) 316 | 317 | def test_checkone(self): 318 | with self.runner.isolated_filesystem(): 319 | self.setUpRepo() 320 | self.setConfig() 321 | a = self.addCommit("a") 322 | result = self.invoke('check') 323 | self.assertEqual(result.exit_code, 0) 324 | self.assertEqual(result.output, "") 325 | 326 | def test_checkchange(self): 327 | with self.runner.isolated_filesystem(): 328 | self.setUpRepo() 329 | self.setConfig() 330 | self.git.config(["privacy.ignoreTimezone", "false"]) # default is ignore 331 | os.environ['TZ'] = 'Europe/London' 332 | time.tzset() 333 | a = self.addCommit("a") 334 | os.environ['TZ'] = 'Europe/Berlin' 335 | time.tzset() 336 | result = self.invoke('check') 337 | self.assertTrue(result.output.startswith( 338 | "Warning: Your timezone has changed")) 339 | self.assertEqual(result.exit_code, 2) 340 | 341 | def test_checkchange_quotedmail(self): 342 | with self.runner.isolated_filesystem(): 343 | self.setUpRepo() 344 | self.setConfig() 345 | email = "johndoe@example.com" 346 | email_quoted = f'"{email}"' 347 | with self.repo.config_writer() as config: 348 | config.set_value("user", "email", email_quoted) 349 | self.git.config(["privacy.ignoreTimezone", "false"]) # default is ignore 350 | os.environ['TZ'] = 'Europe/London' 351 | time.tzset() 352 | a = self.addCommit("a") 353 | self.assertEqual(a.author.email, email) 354 | os.environ['TZ'] = 'Europe/Berlin' 355 | time.tzset() 356 | result = self.invoke('check') 357 | self.assertTrue(result.output.startswith( 358 | "Warning: Your timezone has changed")) 359 | self.assertEqual(result.exit_code, 2) 360 | 361 | def test_checkchangeignore(self): 362 | with self.runner.isolated_filesystem(): 363 | self.setUpRepo() 364 | self.setConfig() 365 | self.git.config(["privacy.ignoreTimezone", "true"]) 366 | os.environ['TZ'] = 'Europe/London' 367 | time.tzset() 368 | a = self.addCommit("a") 369 | os.environ['TZ'] = 'Europe/Berlin' 370 | time.tzset() 371 | result = self.invoke('check') 372 | self.assertEqual(result.exit_code, 0) 373 | self.assertTrue(result.output.startswith( 374 | "Warning: Your timezone has changed")) 375 | 376 | def test_checkwithhook(self): 377 | with self.runner.isolated_filesystem(): 378 | self.setUpRepo() 379 | self.setConfig() 380 | result = self.invoke('init') 381 | self.assertEqual(result.exit_code, 0) 382 | os.environ['TZ'] = 'Europe/London' 383 | time.tzset() 384 | a = self.addCommit("a") 385 | os.environ['TZ'] = 'Europe/Berlin' 386 | time.tzset() 387 | self.addCommit("b") # should not fail 388 | with self.runner.isolated_filesystem(): 389 | self.setUpRepo() 390 | self.setConfig() 391 | result = self.invoke('init --timezone-change=abort') 392 | self.assertEqual(result.exit_code, 0) 393 | os.environ['TZ'] = 'Europe/London' 394 | time.tzset() 395 | a = self.addCommit("a") 396 | os.environ['TZ'] = 'Europe/Berlin' 397 | time.tzset() 398 | with self.assertRaises(git.GitCommandError): 399 | self.addCommit("b") 400 | 401 | def test_checkdifferentusers(self): 402 | with self.runner.isolated_filesystem(): 403 | self.setUpRepo() 404 | self.setConfig() 405 | self.git.config(["privacy.ignoreTimezone", "false"]) # default is ignore 406 | os.environ['TZ'] = 'Europe/London' 407 | time.tzset() 408 | self.git.config(["user.email", "doe@example.com"]) 409 | a = self.addCommit("a") 410 | os.environ['TZ'] = 'Europe/Berlin' 411 | time.tzset() 412 | result = self.invoke('check') 413 | self.assertEqual(result.exit_code, 2) 414 | self.git.config(["user.email", "johndoe@example.com"]) 415 | result = self.invoke('check') 416 | self.assertEqual(result.exit_code, 0) 417 | self.assertIn( 418 | "info: Skipping tzcheck - no previous commits with this email", 419 | result.output, 420 | ) 421 | 422 | def get_real_dates(self, commit): 423 | import gitprivacy.encoder.msgembed as msgenc 424 | conf = GitPrivacyConfig(".") 425 | crypto = conf.get_crypto() 426 | self.assertNotEqual(crypto, None) 427 | decoder = msgenc.MessageEmbeddingDecoder(crypto) 428 | return decoder.decode(commit) 429 | 430 | def test_encryptdates(self): 431 | from gitprivacy import utils 432 | with self.runner.isolated_filesystem(): 433 | self.setUpRepo() 434 | self.setConfig() 435 | result = self.invoke('keys --init') 436 | self.assertEqual(result.exit_code, 0) 437 | a = self.addCommit("a") 438 | result = self.invoke('log') 439 | self.assertEqual(result.exit_code, 0) 440 | self.assertFalse("RealDate" in result.output) 441 | result = self.invoke('redate') 442 | self.assertEqual(result.exit_code, 0) 443 | result = self.invoke('log') 444 | self.assertEqual(result.exit_code, 0) 445 | self.assertTrue("RealDate" in result.output) 446 | # check decrypted date correctness 447 | somedt = datetime(2020, 1, 1, 6, 0, tzinfo=timezone(timedelta(0, 1800))) 448 | self.assertEqual(utils.dt2gitdate(somedt), '1577856600 +0030') 449 | self.assertEqual(utils.gitdate2dt('1577856600 +0030'), somedt) 450 | self.assertEqual( 451 | a.authored_datetime, 452 | utils.gitdate2dt(utils.dt2gitdate(a.authored_datetime)), 453 | ) 454 | ar = self.repo.head.commit 455 | real_ad, real_cd = self.get_real_dates(ar) 456 | self.assertEqual(real_ad, a.authored_datetime) 457 | self.assertEqual(real_cd, a.authored_datetime) 458 | 459 | def test_msgembedciphercompatability(self): 460 | import gitprivacy.encoder.msgembed as msgenc 461 | with self.runner.isolated_filesystem(): 462 | self.setUpRepo() 463 | self.setConfig() 464 | self.git.config(["privacy.password", "foobar"]) 465 | self.git.config(["privacy.salt", "U16/n+bWLbp/MJ9DEo+Th+bbpJjYMZ7yQSUwJmk0QWQ="]) 466 | conf = GitPrivacyConfig(".") 467 | crypto = conf.get_crypto() 468 | # old combined cipher mode 469 | ad, cd = msgenc._decrypt_from_msg( 470 | crypto, 471 | "a\n\nGitPrivacy: Tsfmwy/PQxvg5YkXT90G/7FmCYTzf1ionUnLAqCj08HMG6SAzTQSxLfoF/7OYMzHFXh6apb8OcqcIQY2fGnajGcrXauoQCMZYA==\n" 472 | ) 473 | self.assertNotEqual(ad, None) 474 | self.assertNotEqual(cd, None) 475 | # separate cipher mode 476 | ad, cd = msgenc._decrypt_from_msg( 477 | crypto, 478 | "b\n\nGitPrivacy: 5+cmNIqj6DgRj2e00gHvTI+Llok5eOI6+o59IlGaize/SDHkKrLssqdXd8qzE7sbN6s6l+gen8E= NlfePlKFKT3L/Twi/9BcF/1pJYz0xoedTs7veoeAA9zpzMPOjg9vxMle3oYoPEFbrGb9pOgHqcU=\n" 479 | ) 480 | self.assertNotEqual(ad, None) 481 | self.assertNotEqual(cd, None) 482 | 483 | def test_msgembedciphercompatability_keyfile(self): 484 | import gitprivacy.crypto as gpcrypto 485 | import gitprivacy.encoder.msgembed as msgenc 486 | with self.runner.isolated_filesystem(): 487 | self.setUpRepo() 488 | self.setConfig() 489 | self.git.config(["privacy.password", "foobar"]) 490 | self.git.config(["privacy.salt", "U16/n+bWLbp/MJ9DEo+Th+bbpJjYMZ7yQSUwJmk0QWQ="]) 491 | # migrate to keyfile 492 | result = self.invoke('keys --migrate-pwd') 493 | self.assertEqual(result.exit_code, 0) 494 | conf = GitPrivacyConfig(".") 495 | self.assertEqual(conf.password, "") 496 | self.assertEqual(conf.salt, "") 497 | crypto = conf.get_crypto() 498 | self.assertIsInstance(crypto, gpcrypto.MultiSecretBox) 499 | # old combined cipher mode 500 | ad, cd = msgenc._decrypt_from_msg( 501 | crypto, 502 | "a\n\nGitPrivacy: Tsfmwy/PQxvg5YkXT90G/7FmCYTzf1ionUnLAqCj08HMG6SAzTQSxLfoF/7OYMzHFXh6apb8OcqcIQY2fGnajGcrXauoQCMZYA==\n" 503 | ) 504 | self.assertNotEqual(ad, None) 505 | self.assertNotEqual(cd, None) 506 | # separate cipher mode 507 | ad, cd = msgenc._decrypt_from_msg( 508 | crypto, 509 | "b\n\nGitPrivacy: 5+cmNIqj6DgRj2e00gHvTI+Llok5eOI6+o59IlGaize/SDHkKrLssqdXd8qzE7sbN6s6l+gen8E= NlfePlKFKT3L/Twi/9BcF/1pJYz0xoedTs7veoeAA9zpzMPOjg9vxMle3oYoPEFbrGb9pOgHqcU=\n" 510 | ) 511 | self.assertNotEqual(ad, None) 512 | self.assertNotEqual(cd, None) 513 | 514 | 515 | def test_pwdmismatch(self): 516 | with self.runner.isolated_filesystem(): 517 | self.setUpRepo() 518 | self.setConfig() 519 | self.git.config(["privacy.password", "passw0ord"]) 520 | result = self.invoke('init') 521 | self.assertEqual(result.exit_code, 0) 522 | a = self.addCommit("a") 523 | self.git.config(["privacy.password", "geheim"]) 524 | result = self.invoke('log') 525 | self.assertEqual(result.exit_code, 0) 526 | self.assertFalse("RealDate" in result.output) 527 | 528 | 529 | def test_redatestability(self): 530 | with self.runner.isolated_filesystem(): 531 | self.setUpRepo() 532 | self.setConfig() 533 | result = self.invoke('keys --init') 534 | self.assertEqual(result.exit_code, 0) 535 | a = self.addCommit("a") 536 | result = self.invoke('redate --only-head') 537 | self.assertEqual(result.exit_code, 0) 538 | ar = self.repo.head.commit 539 | self.assertNotEqual(a.hexsha, ar.hexsha) 540 | # do nothing to the repo 541 | result = self.invoke('redate --only-head') 542 | self.assertEqual(result.exit_code, 0) 543 | ar2 = self.repo.head.commit 544 | # redate should not have altered anything 545 | self.assertEqual(ar.message, ar2.message) 546 | self.assertEqual(ar.hexsha, ar2.hexsha) 547 | 548 | 549 | def test_commitdateupdate(self): 550 | import gitprivacy.encoder.msgembed as msgenc 551 | from gitprivacy import utils 552 | with self.runner.isolated_filesystem(): 553 | self.setUpRepo() 554 | self.setConfig() 555 | result = self.invoke('keys --init') 556 | self.assertEqual(result.exit_code, 0) 557 | a = self.addCommit("a") 558 | result = self.invoke('redate --only-head') 559 | self.assertEqual(result.exit_code, 0) 560 | ar = self.repo.head.commit 561 | real_ad, real_cd = self.get_real_dates(ar) 562 | self.assertEqual(real_ad, a.authored_datetime) 563 | self.assertEqual(real_cd, a.committed_datetime) 564 | time.sleep(1) # make sure update commit date is different 565 | res, _, _ = self.git.commit([ 566 | "-m", ar.message, 567 | "--amend", 568 | ], with_extended_output=True) 569 | self.assertEqual(res, 0) 570 | au = copy.copy(self.repo.head.commit) 571 | # amend updated only commit date 572 | self.assertEqual(au.authored_datetime, ar.authored_datetime) 573 | self.assertNotEqual(au.committed_datetime, ar.committed_datetime) 574 | self.assertEqual(ar.message, au.message) 575 | result = self.invoke('redate --only-head') 576 | self.assertEqual(result.exit_code, 0) 577 | aur = copy.copy(self.repo.head.commit) 578 | self.assertNotEqual(au.message, aur.message) 579 | u_real_ad, u_real_cd = self.get_real_dates(aur) 580 | self.assertEqual(au.authored_datetime, aur.authored_datetime) 581 | self.assertNotEqual(au.committed_datetime, aur.committed_datetime) 582 | self.assertEqual(u_real_ad, a.authored_datetime) 583 | self.assertEqual(u_real_cd, au.committed_datetime) 584 | self.assertEqual(real_ad, u_real_ad) 585 | self.assertNotEqual(real_cd, u_real_cd) 586 | 587 | 588 | def load_exported_commit(self, path): 589 | from pkg_resources import resource_stream 590 | with resource_stream('tests', path) as input_fd: 591 | res, stdout, stderr = self.git.fast_import( 592 | "--force", # discard existing commits 593 | istream=input_fd, 594 | with_extended_output=True, 595 | ) 596 | self.assertEqual(res, 0) 597 | 598 | 599 | def test_cipherregression(self): 600 | with self.runner.isolated_filesystem(): 601 | self.setUpRepo() 602 | self.setConfig() 603 | self.git.config(["privacy.password", "foobar"]) 604 | self.git.config(["privacy.salt", "U16/n+bWLbp/MJ9DEo+Th+bbpJjYMZ7yQSUwJmk0QWQ="]) 605 | # combined cipher format 606 | self.load_exported_commit('data/commit_cipher_combined') 607 | c = self.repo.head.commit 608 | real_ad, real_cd = self.get_real_dates(c) 609 | tzinfo = timezone(timedelta(0, 7200)) 610 | self.assertEqual(real_ad, datetime(2020, 6, 29, 10, 6, 1, tzinfo=tzinfo)) 611 | self.assertEqual(real_cd, datetime(2020, 6, 29, 10, 6, 1, tzinfo=tzinfo)) 612 | # mixed cipher format 613 | self.load_exported_commit('data/commit_cipher_mixed') 614 | c = self.repo.head.commit 615 | real_ad, real_cd = self.get_real_dates(c) 616 | tzinfo = timezone(timedelta(0, 7200)) 617 | self.assertEqual(real_ad, datetime(2020, 6, 29, 10, 6, 1, tzinfo=tzinfo)) 618 | self.assertEqual(real_cd, datetime(2020, 6, 29, 11, 0, 24, tzinfo=tzinfo)) 619 | # dedicated cipher format 620 | self.load_exported_commit('data/commit_cipher_dedicated') 621 | c = self.repo.head.commit 622 | real_ad, real_cd = self.get_real_dates(c) 623 | tzinfo = timezone(timedelta(0, 7200)) 624 | self.assertEqual(real_ad, datetime(2020, 6, 29, 11, 3, 23, tzinfo=tzinfo)) 625 | self.assertEqual(real_cd, datetime(2020, 6, 29, 11, 23, 41, tzinfo=tzinfo)) 626 | # dedicated cipher format with different passwords 627 | self.load_exported_commit('data/commit_cipher_diffpwds') 628 | c = self.repo.head.commit 629 | real_ad, real_cd = self.get_real_dates(c) 630 | tzinfo = timezone(timedelta(0, 7200)) 631 | self.assertEqual(real_ad, datetime(2020, 6, 29, 17, 22, 1, tzinfo=tzinfo)) 632 | self.assertEqual(real_cd, None) # diff password – not decryptable 633 | self.git.config(["privacy.password", "foobaz"]) 634 | real_ad, real_cd = self.get_real_dates(c) 635 | self.assertEqual(real_ad, None) # diff password – not decryptable 636 | self.assertEqual(real_cd, datetime(2020, 6, 29, 17, 23, 25, tzinfo=tzinfo)) 637 | self.git.config(["privacy.password", "foobauz"]) 638 | real_ad, real_cd = self.get_real_dates(c) 639 | self.assertEqual(real_ad, None) # diff password – not decryptable 640 | self.assertEqual(real_cd, None) # diff password – not decryptable 641 | 642 | 643 | def test_redactemail(self): 644 | with self.runner.isolated_filesystem(): 645 | self.setUpRepo() 646 | self.setConfig() 647 | email = "privat@example.com" 648 | self.git.config(["user.email", email]) 649 | a = self.addCommit("a") 650 | self.assertEqual(a.author.email, email) 651 | result = self.invoke(f'redact-email') 652 | self.assertEqual(result.exit_code, 0) 653 | result = self.invoke(f'redact-email {email}') 654 | self.assertEqual(result.exit_code, 0) 655 | result = self.invoke('log') 656 | self.assertEqual(result.exit_code, 0) 657 | self.assertFalse(email in result.output) 658 | commit = self.repo.head.commit 659 | self.assertNotEqual(commit.author.email, email) 660 | self.assertNotEqual(commit.committer.email, email) 661 | 662 | 663 | def test_redactemailcustomreplacement(self): 664 | with self.runner.isolated_filesystem(): 665 | self.setUpRepo() 666 | self.setConfig() 667 | name = "John Doe" 668 | email = "privat@example.com" 669 | repl = "public@example.com" 670 | self.git.config(["user.name", name]) 671 | self.git.config(["user.email", email]) 672 | a = self.addCommit("a") 673 | self.assertEqual(a.author.name, name) 674 | self.assertEqual(a.author.email, email) 675 | result = self.invoke(f'redact-email {email}:{repl}:too:many') 676 | self.assertEqual(result.exit_code, 2) 677 | result = self.invoke(f'redact-email {email}:{repl}') 678 | self.assertEqual(result.exit_code, 0) 679 | result = self.invoke('log') 680 | self.assertEqual(result.exit_code, 0) 681 | self.assertFalse(email in result.output) 682 | self.assertTrue(repl in result.output) 683 | commit = self.repo.head.commit 684 | self.assertEqual(commit.author.name, name) 685 | self.assertEqual(commit.author.email, repl) 686 | self.assertEqual(commit.committer.email, repl) 687 | # replace back and change name 688 | new_name = "Doe, John" 689 | result = self.invoke(f'redact-email {repl}:{email}:"{new_name}"') 690 | self.assertEqual(result.exit_code, 0) 691 | commit = self.repo.head.commit 692 | self.assertEqual(commit.author.name, new_name) 693 | self.assertEqual(commit.author.email, email) 694 | self.assertEqual(commit.committer.name, new_name) 695 | self.assertEqual(commit.committer.email, email) 696 | 697 | def test_globaltemplate(self): 698 | templdir = os.path.join(self.home, ".git_template") 699 | with self.runner.isolated_filesystem(): 700 | os.mkdir(self.home) 701 | self.setUpRepo() 702 | self.setConfig() 703 | result = self.invoke('init -g') 704 | self.assertEqual(result.exception, None) 705 | self.assertEqual(result.exit_code, 0) 706 | self.assertEqual(result.output, os.linesep.join( 707 | f"Installed {hook} hook" 708 | for hook in ["post-commit", "pre-commit", "post-rewrite", 709 | "pre-push"] 710 | ) + os.linesep) 711 | # local Git repo initialised BEFORE global template was set up 712 | # hence the hooks are not present and active locally yet 713 | self.assertFalse(os.access(os.path.join(".git", "hooks", "post-commit"), 714 | os.R_OK | os.X_OK)) # not installed locally 715 | self.assertFalse(os.access(os.path.join(".git", "hooks", "pre-commit"), 716 | os.F_OK)) 717 | self.assertTrue(os.access(os.path.join(templdir, "hooks", "post-commit"), 718 | os.R_OK | os.X_OK)) 719 | self.assertTrue(os.access(os.path.join(templdir, "hooks", "pre-commit"), 720 | os.F_OK)) 721 | a = self.addCommit("a") # gitpython already returns the rewritten commit 722 | self.assertNotEqual(a.authored_datetime, 723 | a.authored_datetime.replace(minute=0, second=0)) 724 | 725 | # Now reinit local repo to fetch template hooks 726 | self.setUpRepo() 727 | self.assertTrue(os.access(os.path.join(".git", "hooks", "post-commit"), 728 | os.R_OK | os.X_OK)) # now installed locally too 729 | self.assertTrue(os.access(os.path.join(".git", "hooks", "pre-commit"), 730 | os.F_OK)) 731 | b = self.addCommit("b") # gitpython already returns the rewritten commit 732 | self.assertEqual(b.authored_datetime, 733 | b.authored_datetime.replace(minute=0, second=0)) 734 | 735 | def test_globaltemplate_init_outside_repo(self): 736 | home = ".home" 737 | templdir = os.path.join(home, ".git_template") 738 | with self.runner.isolated_filesystem(), \ 739 | self.runner.isolation(env=dict(HOME=home)): 740 | os.mkdir(home) 741 | result = self.invoke('init -g') 742 | self.assertEqual(result.exit_code, 2) 743 | # installing a global hooks outside of a local repo is currently 744 | # not possible, as repo checks are run before any command 745 | 746 | def does_cherrypick_run_postcommit(self) -> bool: 747 | with self.runner.isolated_filesystem(): 748 | self.setUpRepo() 749 | hookdir = os.path.join(".git", "hooks") 750 | hookpath = os.path.join(hookdir, "post-commit") 751 | if not os.path.exists(hookdir): 752 | os.mkdir(hookdir) 753 | with open(hookpath, "w") as f: 754 | f.write("/bin/sh\n\necho DEADBEEF") 755 | os.chmod(hookpath, 0o755) 756 | a = self.addCommit("a") 757 | res, stdout, stderr = self.git.execute( 758 | ["git", "cherry-pick", "--keep-redundant-commits", "HEAD"], 759 | with_extended_output=True, 760 | ) 761 | self.git = None 762 | self.repo = None 763 | return "DEADBEEF" in stderr 764 | 765 | def test_rebase(self): 766 | cherryhook_active = self.does_cherrypick_run_postcommit() 767 | with self.runner.isolated_filesystem(): 768 | self.setUpRepo() 769 | self.setConfig() 770 | a = self.addCommit("a") 771 | c = self.addCommit("c") 772 | b = self.addCommit("b") 773 | def _log(): 774 | return [c.message.strip() for c in self.repo.iter_commits()] 775 | self.assertEqual(_log(), ["b", "c", "a"]) 776 | # swap last two commits 777 | def _rebase_cmds(): 778 | return ( 779 | f"p {self.repo.commit('HEAD')}" 780 | "\n" 781 | f"p {self.repo.commit('HEAD^')}" 782 | ) 783 | res, stdout, stderr = self.git.rebase( 784 | ["-q", "-i", "HEAD~2"], 785 | env=dict(GIT_SEQUENCE_EDITOR=f"echo '{_rebase_cmds()}' >"), 786 | with_extended_output=True, 787 | ) 788 | self.assertEqual(res, 0) 789 | self.assertEqual(_log(), ["c", "b", "a"]) 790 | self.assertEqual(stdout, "") 791 | self.assertNotIn("git.exc.GitCommandError", stderr) 792 | self.assertNotIn("cherry-pick in progress", stderr) 793 | # init git-privacy and try once more 794 | result = self.invoke('init') 795 | self.assertEqual(result.exit_code, 0) 796 | # swap last two commits back 797 | res, stdout, stderr = self.git.rebase( 798 | ["-q", "-i", "HEAD~2"], 799 | env=dict(GIT_SEQUENCE_EDITOR=f"echo '{_rebase_cmds()}' >"), 800 | with_extended_output=True, 801 | ) 802 | self.assertEqual(res, 0) 803 | self.assertEqual(_log(), ["b", "c", "a"]) 804 | self.assertEqual(stdout, "") 805 | self.assertNotIn("git.exc.GitCommandError", stderr) 806 | self.assertIn("redate-rewrites", stderr) # logged redates 807 | # check result of redating during rebase 808 | # depending on external factors a cherry-pick might not have 809 | # concluded. Distinguish both cases. 810 | br = self.repo.head.commit 811 | cr = self.repo.commit("HEAD^") 812 | if not cherryhook_active or "cherry-pick in progress" in stderr: 813 | # no redate 814 | self.assertEqual(b.authored_date, br.authored_date) 815 | self.assertEqual(c.authored_date, cr.authored_date) 816 | else: 817 | # redated 818 | self.assertNotEqual(b.authored_date, br.authored_date) 819 | self.assertNotEqual(c.authored_date, cr.authored_date) 820 | 821 | def test_rewritelog(self): 822 | with self.runner.isolated_filesystem(): 823 | self.setUpRepo() 824 | self.setConfig() 825 | result = self.invoke('init') 826 | self.assertEqual(result.exit_code, 0) 827 | # check redate empty repo 828 | result = self.invoke('redate-rewrites') 829 | self.assertEqual(result.exit_code, 128) 830 | # check redate without pending rewrites 831 | a = self.addCommit("a") 832 | result = self.invoke('redate-rewrites') 833 | self.assertEqual(result.exit_code, 0) 834 | self.assertEqual(result.output, "No pending rewrites to redact\n") 835 | # make sure an amend does not log a rewrite 836 | # because the post-commit hook already took care 837 | res, _, stderr = self.git.commit([ 838 | "--no-edit", 839 | "--amend", 840 | ], with_extended_output=True) 841 | self.assertEqual(res, 0) 842 | self.assertNotIn("redate-rewrites", stderr) 843 | # add two more commits and do some rebasing 844 | b = self.addCommit("b") 845 | c = self.addCommit("c") 846 | # swap last two commits 847 | def _rebase_cmds(): 848 | return ( 849 | f"p {self.repo.commit('HEAD')}" 850 | "\n" 851 | f"p {self.repo.commit('HEAD^')}" 852 | ) 853 | res, stdout, stderr = self.git.rebase( 854 | ["-q", "-i", "HEAD~2"], 855 | env=dict(GIT_SEQUENCE_EDITOR=f"echo '{_rebase_cmds()}' >"), 856 | with_extended_output=True, 857 | ) 858 | self.assertEqual(res, 0) 859 | self.assertEqual(stdout, "") 860 | self.assertIn("redate-rewrites", stderr) # logged redates 861 | br = self.repo.head.commit 862 | cr = self.repo.commit("HEAD^") 863 | rwpath = os.path.join(self.repo.git_dir, "privacy", "rewrites") 864 | with open(rwpath) as f: 865 | rewrites = f.read() 866 | self.assertEqual(len(rewrites.splitlines()), 2) 867 | self.assertIn(br.hexsha, rewrites) 868 | self.assertIn(cr.hexsha, rewrites) 869 | self.assertFalse(self._is_loose(br)) 870 | self.assertFalse(self._is_loose(cr)) 871 | # redate rewrites 872 | result = self.invoke('redate-rewrites') 873 | self.assertEqual(result.exit_code, 0) 874 | # check result of redating 875 | self.assertTrue(self._is_loose(br)) 876 | self.assertTrue(self._is_loose(cr)) 877 | # rw log should be deleted after redating 878 | self.assertFalse(os.path.exists(rwpath)) 879 | 880 | def _is_loose(self, commit) -> bool: 881 | try: 882 | return self.git.branch("--contains", commit.hexsha) == "" 883 | except git.GitCommandError: 884 | return True # cannot even find commit anymore 885 | 886 | def test_replace(self): 887 | with self.runner.isolated_filesystem(): 888 | self.setUpRepo() 889 | self.setConfig() 890 | self.git.config(["privacy.replacements", "true"]) 891 | # test replacement set by FilterRepoRewriter 892 | a = self.addCommit("a") 893 | result = self.invoke('redate') 894 | self.assertEqual(result.exit_code, 0) 895 | ar = self.repo.head.commit 896 | self.assertNotEqual(a, ar) 897 | rpls = self.git.replace("-l", "--format=medium").splitlines() 898 | self.assertEqual(len(rpls), 1) 899 | self.assertIn(f"{a.hexsha} -> {ar.hexsha}", rpls) 900 | # test replacement set by AmendRewriter 901 | b = self.addCommit("b") 902 | result = self.invoke('redate --only-head') 903 | self.assertEqual(result.exit_code, 0) 904 | br = self.repo.head.commit 905 | self.assertNotEqual(b, br) 906 | rpls = self.git.replace("-l", "--format=medium").splitlines() 907 | self.assertEqual(len(rpls), 2) 908 | self.assertIn(f"{b.hexsha} -> {br.hexsha}", rpls) 909 | # test without replacements 910 | self.git.config(["--unset", "privacy.replacements"]) 911 | self.addCommit("c") 912 | result = self.invoke('redate') 913 | self.assertEqual(result.exit_code, 0) 914 | self.addCommit("d") 915 | result = self.invoke('redate --only-head') 916 | self.assertEqual(result.exit_code, 0) 917 | self.assertEqual(len(rpls), 2) # no further replacements 918 | 919 | def test_pwdmigration(self): 920 | with self.runner.isolated_filesystem(): 921 | self.setUpRepo() 922 | self.setConfig() 923 | self.git.config(["privacy.password", "foobar"]) 924 | self.git.config(["privacy.salt", "U16/n+bWLbp/MJ9DEo+Th+bbpJjYMZ7yQSUwJmk0QWQ="]) 925 | # any non-migrate command should fail – since there is still a 926 | # password set in the config 927 | result = self.invoke('keys --init') 928 | self.assertEqual(result.exit_code, 1) 929 | self.assertFalse(os.path.isfile(".git/privacy/keys/current")) 930 | # ... then migrate 931 | result = self.invoke('keys --migrate-pwd') 932 | self.assertEqual(result.exit_code, 0) 933 | self.assertTrue(os.path.isfile(".git/privacy/keys/current")) 934 | # password and salt config settings are gone (commented out) 935 | with self.repo.config_reader() as config: 936 | self.assertFalse(config.has_option("privacy", "password")) 937 | self.assertFalse(config.has_option("privacy", "salt")) 938 | # now migrate should fail – since there is no password anymore 939 | result = self.invoke('keys --migrate-pwd') 940 | self.assertEqual(result.exit_code, 1) 941 | 942 | def test_pwdmigration_with_previous_key(self): 943 | with self.runner.isolated_filesystem(): 944 | self.setUpRepo() 945 | self.setConfig() 946 | # generate key 947 | result = self.invoke('keys --init') 948 | self.assertEqual(result.exit_code, 0) 949 | # set password 950 | self.git.config(["privacy.password", "foobar"]) 951 | self.git.config(["privacy.salt", "U16/n+bWLbp/MJ9DEo+Th+bbpJjYMZ7yQSUwJmk0QWQ="]) 952 | # ... then migrate 953 | result = self.invoke('keys --migrate-pwd') 954 | self.assertEqual(result.exit_code, 1) # fails because no confirmation 955 | # password and salt config settings are still there 956 | with self.repo.config_reader() as config: 957 | self.assertTrue(config.has_option("privacy", "password")) 958 | self.assertTrue(config.has_option("privacy", "salt")) 959 | 960 | def test_key_activation_deactivation(self): 961 | with self.runner.isolated_filesystem(): 962 | self.setUpRepo() 963 | self.setConfig() 964 | # try disable key without any key present 965 | result = self.invoke('keys --disable') 966 | self.assertEqual(result.exit_code, 1) 967 | # init 968 | result = self.invoke('keys --init') 969 | self.assertEqual(result.exit_code, 0) 970 | self.assertTrue(os.path.isfile(".git/privacy/keys/current")) 971 | self.assertFalse(os.path.isfile(".git/privacy/keys/archive/1")) 972 | # disable key 973 | result = self.invoke('keys --disable') 974 | self.assertEqual(result.exit_code, 0) 975 | self.assertFalse(os.path.isfile(".git/privacy/keys/current")) 976 | self.assertTrue(os.path.isfile(".git/privacy/keys/archive/1")) 977 | 978 | def test_key_renewal(self): 979 | current_p = pathlib.Path(".git/privacy/keys/current") 980 | archive1_p = pathlib.Path(".git/privacy/keys/archive/1") 981 | archive2_p = pathlib.Path(".git/privacy/keys/archive/2") 982 | with self.runner.isolated_filesystem(): 983 | self.setUpRepo() 984 | self.setConfig() 985 | # check renewal without previous key 986 | result = self.invoke('keys --new') 987 | self.assertEqual(result.exit_code, 1) 988 | # generate init key 989 | result = self.invoke('keys --init') 990 | self.assertEqual(result.exit_code, 0) 991 | self.assertTrue(current_p.is_file()) 992 | self.assertFalse(archive1_p.is_file()) 993 | old_key = current_p.read_text() 994 | # renew key 995 | result = self.invoke('keys --new') 996 | self.assertEqual(result.exit_code, 0) 997 | self.assertTrue(current_p.is_file()) 998 | self.assertTrue(archive1_p.is_file()) 999 | a1_key = archive1_p.read_text() 1000 | self.assertEqual(old_key, a1_key) 1001 | # test --no-archive 1002 | result = self.invoke('keys --new --no-archive') 1003 | self.assertEqual(result.exit_code, 0) 1004 | self.assertFalse(archive2_p.is_file()) 1005 | # test repeated --init 1006 | result = self.invoke('keys --init') 1007 | self.assertEqual(result.exit_code, 1) 1008 | 1009 | def test_prepush_check(self): 1010 | with self.runner.isolated_filesystem(): 1011 | self.setUpRepo() 1012 | remote = self.setUpRemote() 1013 | # commit before git-privacy init to produce unredacted ts 1014 | a = self.addCommit("a") 1015 | self.setConfig() 1016 | result = self.invoke('init') 1017 | self.assertEqual(result.exit_code, 0) 1018 | # try to push them unredacted 1019 | with self.assertRaises(git.GitCommandError) as cm: 1020 | self.git.push( 1021 | [remote.name, self.repo.active_branch], 1022 | ) 1023 | self.assertEqual(cm.exception.status, 1) 1024 | self.assertIn( 1025 | 'You tried to push commits with unredacted timestamps:', 1026 | cm.exception.stderr, 1027 | ) 1028 | self.assertIn(a.hexsha, cm.exception.stderr) 1029 | # make shure no redate base argument is suggested (first commit) 1030 | self.assertRegex(cm.exception.stderr, r"(?m)git-privacy redate$") 1031 | # make shure other remote warning is not shown 1032 | self.assertNotRegex(cm.exception.stderr, r"(?m)^WARNING:") 1033 | # try to force-push them unredacted – should make no difference 1034 | with self.assertRaises(git.GitCommandError) as cm: 1035 | self.git.push( 1036 | ["-f", remote.name, self.repo.active_branch], 1037 | ) 1038 | self.assertEqual(cm.exception.status, 1) 1039 | self.assertIn( 1040 | 'You tried to push commits with unredacted timestamps:', 1041 | cm.exception.stderr, 1042 | ) 1043 | # redate and then push – should work 1044 | result = self.invoke('redate') 1045 | self.assertEqual(result.exit_code, 0) 1046 | ar = self.repo.head.commit 1047 | res, _stdout, _stderr = self.git.push( 1048 | [remote.name, self.repo.active_branch], 1049 | with_extended_output=True, 1050 | ) 1051 | self.assertEqual(res, 0) 1052 | # now try with multiple non-initial unredacted commits 1053 | # ... but remove post-commit hook before to prevent redating 1054 | os.remove(".git/hooks/post-commit") 1055 | b = self.addCommit("b") 1056 | c = self.addCommit("c") 1057 | with self.assertRaises(git.GitCommandError) as cm: 1058 | res_tuple = self.git.push( 1059 | [remote.name, self.repo.active_branch], 1060 | with_extended_output=True, 1061 | ) 1062 | raise RuntimeError(res_tuple) 1063 | self.assertEqual(cm.exception.status, 1) 1064 | self.assertIn( 1065 | 'You tried to push commits with unredacted timestamps:', 1066 | cm.exception.stderr, 1067 | ) 1068 | self.assertRegex(cm.exception.stderr, fr"(?m)^{b.hexsha}$") 1069 | self.assertRegex(cm.exception.stderr, fr"(?m)^{c.hexsha}$") 1070 | named_redate_base = utils.get_named_ref(ar) 1071 | self.assertRegex(cm.exception.stderr, 1072 | fr"(?m)git-privacy redate {named_redate_base}$") 1073 | # make shure other remote warning is not shown 1074 | self.assertNotRegex(cm.exception.stderr, r"(?m)^WARNING:") 1075 | # again, redate local changes and then push – should work 1076 | result = self.invoke('redate origin/master') 1077 | #self.assertEqual(result.output, "") 1078 | self.assertEqual(result.exit_code, 0) 1079 | cr = self.repo.head.commit 1080 | self.assertNotEqual(cr.hexsha, c.hexsha) 1081 | res, _stdout, _stderr = self.git.push( 1082 | [remote.name, self.repo.active_branch], 1083 | with_extended_output=True, 1084 | ) 1085 | self.assertEqual(res, 0) 1086 | # push to separate remote branch and delete it 1087 | res, _stdout, _stderr = self.git.push( 1088 | [remote.name, f"{self.repo.active_branch}:foobar"], 1089 | with_extended_output=True, 1090 | ) 1091 | self.assertEqual(res, 0) 1092 | res, _stdout, _stderr = self.git.push( 1093 | ["-d", remote.name, "foobar"], 1094 | with_extended_output=True, 1095 | ) 1096 | self.assertEqual(res, 0) 1097 | 1098 | def test_prepush_check_multiple_remotes(self): 1099 | with self.runner.isolated_filesystem(): 1100 | self.setUpRepo() 1101 | r_origin = self.setUpRemote() 1102 | r_tomato = self.setUpRemote("tomato") 1103 | # commit before git-privacy init to produce unredacted ts 1104 | a = self.addCommit("a") 1105 | b = self.addCommit("b") 1106 | c = self.addCommit("c") 1107 | # push to tomato before git-privacy init 1108 | res, _stdout, _stderr = self.git.push( 1109 | [r_tomato.name, self.repo.active_branch], 1110 | with_extended_output=True, 1111 | ) 1112 | self.assertEqual(res, 0) 1113 | # setup git-privacy and try to push to origin 1114 | self.setConfig() 1115 | result = self.invoke('init') 1116 | self.assertEqual(result.exit_code, 0) 1117 | # try to push them unredacted – should fail 1118 | with self.assertRaises(git.GitCommandError) as cm: 1119 | self.git.push( 1120 | [r_origin.name, self.repo.active_branch], 1121 | ) 1122 | self.assertEqual(cm.exception.status, 1) 1123 | self.assertIn( 1124 | 'You tried to push commits with unredacted timestamps:', 1125 | cm.exception.stderr, 1126 | ) 1127 | self.assertRegex(cm.exception.stderr, fr"(?m)^{a.hexsha}$") 1128 | self.assertRegex(cm.exception.stderr, fr"(?m)^{b.hexsha}$") 1129 | self.assertRegex(cm.exception.stderr, fr"(?m)^{c.hexsha}$") 1130 | # make shure no redate base argument is suggested (first commit) 1131 | self.assertRegex(cm.exception.stderr, r"(?m)git-privacy redate$") 1132 | # check for warning about tomato 1133 | self.assertRegex(cm.exception.stderr, r"(?m)^WARNING:") 1134 | self.assertRegex(cm.exception.stderr, 1135 | fr"(?m)^{r_tomato.name}/{self.repo.active_branch}$") 1136 | 1137 | def test_prepush_check_diverging_remote(self): 1138 | with self.runner.isolated_filesystem(): 1139 | self.setUpRepo() 1140 | # setup git-privacy 1141 | self.setConfig() 1142 | result = self.invoke('init') 1143 | self.assertEqual(result.exit_code, 0) 1144 | r = self.setUpRemote() 1145 | # do a common commit 1146 | self.addCommit("a") 1147 | # push to remote before cloning 1148 | res, _stdout, _stderr = self.git.push( 1149 | [r.name, self.repo.active_branch], 1150 | with_extended_output=True, 1151 | ) 1152 | self.assertEqual(res, 0) 1153 | # make a clone and push an update there 1154 | clone = git.Repo.clone_from(r.url, "clone") 1155 | self.configGit(clone.git) 1156 | self.addCommit("b", repo=clone) 1157 | res, _stdout, _stderr = clone.git.push( 1158 | [r.name, clone.active_branch], 1159 | with_extended_output=True, 1160 | ) 1161 | self.assertEqual(res, 0) 1162 | # make local diverge by adding c 1163 | self.addCommit("c") 1164 | # try to push – should fail and warn about skipping 1165 | with self.assertRaises(git.GitCommandError) as cm: 1166 | self.git.push( 1167 | [r.name, self.repo.active_branch], 1168 | ) 1169 | self.assertEqual(cm.exception.status, 1) 1170 | self.assertIn( 1171 | 'Detected diverging remote.', 1172 | cm.exception.stderr, 1173 | ) 1174 | # ... now force push. Warning remains 1175 | res, _stdout, stderr = self.git.push( 1176 | ["-f", r.name, self.repo.active_branch], 1177 | with_extended_output=True, 1178 | ) 1179 | self.assertEqual(res, 0) 1180 | self.assertIn( 1181 | 'Detected diverging remote.', 1182 | stderr, 1183 | ) 1184 | 1185 | def test_prepush_check_multiple_tags(self): 1186 | with self.runner.isolated_filesystem(): 1187 | self.setUpRepo() 1188 | # setup git-privacy 1189 | self.setConfig() 1190 | result = self.invoke('init') 1191 | self.assertEqual(result.exit_code, 0) 1192 | r = self.setUpRemote() 1193 | # make some commits and tags 1194 | self.addCommit("a") 1195 | self.repo.create_tag("tag_a") 1196 | self.addCommit("b") 1197 | self.repo.create_tag("tag_b") 1198 | # push to remote 1199 | res, _stdout, _stderr = self.git.push( 1200 | [r.name, self.repo.active_branch], 1201 | with_extended_output=True, 1202 | ) 1203 | self.assertEqual(res, 0) 1204 | # ... now push all tags 1205 | res, _stdout, stderr = self.git.push( 1206 | ["--tags", r.name, self.repo.active_branch], 1207 | with_extended_output=True, 1208 | ) 1209 | self.assertEqual(res, 0) 1210 | 1211 | def test_prepush_check_ignore_public_dirty(self): 1212 | with self.runner.isolated_filesystem(): 1213 | self.setUpRepo() 1214 | # make unredacted commit 1215 | r = self.setUpRemote() 1216 | self.addCommit("init") 1217 | res, _stdout, _stderr = self.git.push( 1218 | [r.name, self.repo.active_branch], 1219 | with_extended_output=True, 1220 | ) 1221 | self.addCommit("a") 1222 | # setup git-privacy 1223 | self.setConfig() 1224 | result = self.invoke('init') 1225 | self.assertEqual(result.exit_code, 0) 1226 | # make a tag tags 1227 | self.repo.create_tag("tag_a") 1228 | # fail pushing dirty tag (before commit is public) 1229 | with self.assertRaises(git.GitCommandError) as cm: 1230 | self.git.push( 1231 | [r.name, "tag_a"], 1232 | ) 1233 | self.assertEqual(cm.exception.status, 1) 1234 | # ... now push commits (ignoring push checks) 1235 | res, _stdout, _stderr = self.git.push( 1236 | ["--no-verify", r.name, self.repo.active_branch], 1237 | with_extended_output=True, 1238 | ) 1239 | self.assertEqual(res, 0) 1240 | # ... now successfully pushing dirty tag 1241 | res, _stdout, _stderr = self.git.push( 1242 | [r.name, "tag_a"], 1243 | with_extended_output=True, 1244 | ) 1245 | self.assertEqual(res, 0) 1246 | 1247 | def test_help_commands(self): 1248 | with self.runner.isolated_filesystem(): 1249 | subcmds = cli.list_commands(None) 1250 | for cmd in subcmds: 1251 | with self.subTest(cmd): 1252 | # check if help info is displayed without error 1253 | # in a directory with no repo 1254 | result = self.invoke(f"{cmd} -h") 1255 | self.assertEqual(result.exit_code, 0) 1256 | # check if repo assertions are added everywhere 1257 | # to gracefully inform about lack of repo 1258 | # just invoke each subcommand and make sure no exception is 1259 | # raised 1260 | result = self.invoke(cmd) 1261 | if result.exception: 1262 | self.assertIsInstance(result.exception, SystemExit, cmd) 1263 | 1264 | 1265 | if __name__ == '__main__': 1266 | unittest.main() 1267 | -------------------------------------------------------------------------------- /tests/test_timestamp.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, timedelta 3 | 4 | from gitprivacy.dateredacter import ResolutionDateRedacter 5 | 6 | 7 | class ReduceTestCase(unittest.TestCase): 8 | def setUp(self): 9 | self.full = datetime(year=2018, month=12, day=18, 10 | hour=14, minute=42, second=13) 11 | 12 | def test_seconds(self): 13 | ts = ResolutionDateRedacter(mode="reduce", pattern="s") 14 | expected = datetime(year=2018, month=12, day=18, 15 | hour=14, minute=42, second=0) 16 | self.assertEqual(ts.redact(self.full), expected) 17 | 18 | def test_minute(self): 19 | ts = ResolutionDateRedacter(mode="reduce", pattern="m") 20 | expected = datetime(year=2018, month=12, day=18, 21 | hour=14, minute=0, second=13) 22 | self.assertEqual(ts.redact(self.full), expected) 23 | 24 | def test_hour(self): 25 | ts = ResolutionDateRedacter(mode="reduce", pattern="h") 26 | expected = datetime(year=2018, month=12, day=18, 27 | hour=0, minute=42, second=13) 28 | self.assertEqual(ts.redact(self.full), expected) 29 | 30 | def test_day(self): 31 | ts = ResolutionDateRedacter(mode="reduce", pattern="d") 32 | expected = datetime(year=2018, month=12, day=1, 33 | hour=14, minute=42, second=13) 34 | self.assertEqual(ts.redact(self.full), expected) 35 | 36 | def test_month(self): 37 | ts = ResolutionDateRedacter(mode="reduce", pattern="M") 38 | expected = datetime(year=2018, month=1, day=18, 39 | hour=14, minute=42, second=13) 40 | self.assertEqual(ts.redact(self.full), expected) 41 | 42 | 43 | class LimitTestCase(unittest.TestCase): 44 | def test_before(self): 45 | ts = ResolutionDateRedacter(limit="9-17") 46 | full = datetime(year=2018, month=12, day=18, 47 | hour=8, minute=42, second=15) 48 | expected = datetime(year=2018, month=12, day=18, 49 | hour=9, minute=0, second=0) 50 | self.assertEqual(ts.limit, (9, 17)) 51 | self.assertEqual(ts._enforce_limit(full), expected) 52 | 53 | def test_after(self): 54 | ts = ResolutionDateRedacter(limit="9-17") 55 | full = datetime(year=2018, month=12, day=18, 56 | hour=17, minute=42, second=15) 57 | expected = datetime(year=2018, month=12, day=18, 58 | hour=17, minute=0, second=0) 59 | self.assertEqual(ts.limit, (9, 17)) 60 | self.assertEqual(ts._enforce_limit(full), expected) 61 | --------------------------------------------------------------------------------