├── tests ├── __init__.py ├── dummy_editor.py ├── test_reword.py ├── test_cut.py ├── test_gpgsign.py ├── test_cleanup_editor_content.py ├── conftest.py ├── test_interactive.py ├── test_rerere.py └── test_fixup.py ├── .isort.cfg ├── readthedocs.yml ├── gitrevise ├── __main__.py ├── __init__.py ├── tui.py ├── todo.py ├── merge.py ├── utils.py └── odb.py ├── docs ├── api │ ├── todo.rst │ ├── utils.rst │ ├── merge.rst │ ├── tui.rst │ ├── odb.rst │ └── index.rst ├── install.rst ├── index.rst ├── Makefile ├── contributing.rst ├── performance.rst ├── conf.py └── man.rst ├── mypy.ini ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── LICENSE ├── tox.ini ├── README.md ├── CHANGELOG.md ├── .gitignore ├── pyproject.toml └── git-revise.1 /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.8 6 | 7 | -------------------------------------------------------------------------------- /gitrevise/__main__.py: -------------------------------------------------------------------------------- 1 | from .tui import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /docs/api/todo.rst: -------------------------------------------------------------------------------- 1 | ---------------------------------- 2 | ``todo`` -- History edit sequences 3 | ---------------------------------- 4 | 5 | .. automodule:: gitrevise.todo 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | --------------------------------- 2 | ``utils`` -- Misc. helper methods 3 | --------------------------------- 4 | 5 | .. automodule:: gitrevise.utils 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/api/merge.rst: -------------------------------------------------------------------------------- 1 | ----------------------------------- 2 | ``merge`` -- Quick in-memory merges 3 | ----------------------------------- 4 | 5 | .. automodule:: gitrevise.merge 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/api/tui.rst: -------------------------------------------------------------------------------- 1 | ------------------------------------- 2 | ``tui`` -- ``git-revise`` entry point 3 | ------------------------------------- 4 | 5 | .. automodule:: gitrevise.tui 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/api/odb.rst: -------------------------------------------------------------------------------- 1 | ------------------------------------------ 2 | ``odb`` -- Git object database interaction 3 | ------------------------------------------ 4 | 5 | .. automodule:: gitrevise.odb 6 | :members: 7 | -------------------------------------------------------------------------------- /gitrevise/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | gitrevise is a library for efficiently working with changes in git repositories. 3 | It holds an in-memory copy of the object database and supports efficient 4 | in-memory merges and rebases. 5 | """ 6 | 7 | __version__ = "0.7.0" 8 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installing 2 | ========== 3 | 4 | :command:`git-revise` can be installed from PyPi_. Python 3.8 or higher is 5 | required. 6 | 7 | .. code-block:: bash 8 | 9 | $ pip install --user git-revise 10 | 11 | .. _PyPi: https://pypi.org/project/git-revise/ 12 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | The ``gitrevise`` module 3 | ======================== 4 | 5 | Python modules for interacting with git objects used by :ref:`git_revise`. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Submodules: 10 | 11 | merge 12 | odb 13 | todo 14 | tui 15 | utils 16 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | warn_return_any = True 4 | warn_unused_configs = True 5 | no_implicit_optional = True 6 | 7 | # strict typing 8 | strict_optional = True 9 | disallow_untyped_calls = True 10 | disallow_untyped_defs = True 11 | disallow_incomplete_defs = True 12 | check_untyped_defs = True 13 | disallow_untyped_decorators = True 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | git-revise 3 | ========== 4 | 5 | ``git-revise`` is a :manpage:`git(1)` subcommand, and :manpage:`python(1)` 6 | library for efficiently updating, splitting, and rearranging commits. 7 | 8 | :command:`git revise` is open-source, and can be found on GitHub_ 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :caption: Contents: 13 | 14 | install 15 | man 16 | performance 17 | api/index 18 | contributing 19 | 20 | .. _GitHub: https://github.com/mystor/git-revise 21 | 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "monthly" 10 | - package-ecosystem: "uv" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | groups: 15 | python-packages: 16 | patterns: 17 | - "*" 18 | -------------------------------------------------------------------------------- /tests/dummy_editor.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from urllib.request import urlopen 4 | 5 | 6 | def run_editor(path: Path, url: str) -> None: 7 | with urlopen(url, data=path.read_bytes(), timeout=10) as request: 8 | length = int(request.headers.get("content-length")) 9 | data = request.read(length) 10 | if request.status != 200: 11 | raise RuntimeError(data.decode()) 12 | path.write_bytes(data) 13 | 14 | 15 | if __name__ == "__main__": 16 | run_editor(url=sys.argv[1], path=Path(sys.argv[2]).resolve()) 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test on python ${{ matrix.python-version }} and ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10"] 13 | os: [ubuntu-latest, windows-latest, macOS-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - uses: astral-sh/setup-uv@v6 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | # TODO(https://github.com/astral-sh/setup-uv/issues/226): Remove this. 22 | prune-cache: ${{ matrix.os != 'windows-latest' }} 23 | 24 | - run: uv sync --all-extras --dev 25 | 26 | - run: uv pip install tox tox-gh-actions 27 | 28 | - run: uv run tox 29 | env: 30 | PLATFORM: ${{ matrix.os }} 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = git-revise 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | uv run @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help man Makefile 16 | 17 | # Copy generated manfile into project root for distribution. 18 | man: Makefile 19 | uv run @$(SPHINXBUILD) -M man "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | cp "$(BUILDDIR)/man/git-revise.1" "$(SOURCEDIR)/.." 21 | 22 | # Catch-all target: route all unknown targets to Sphinx using the new 23 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 24 | %: Makefile 25 | uv run @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Nika Layzell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | py38 9 | py39 10 | py310 11 | mypy 12 | lint 13 | format 14 | 15 | [testenv] 16 | runner = uv-venv-lock-runner 17 | description = pytest for {basepython} 18 | commands = pytest {posargs} 19 | passenv = PROGRAMFILES* # to locate git-bash on windows 20 | 21 | [testenv:mypy] 22 | runner = uv-venv-lock-runner 23 | description = typecheck with mypy 24 | commands = mypy --strict gitrevise tests docs {posargs} 25 | basepython = python3.10 26 | 27 | [testenv:lint] 28 | runner = uv-venv-lock-runner 29 | description = lint with pylint and isort 30 | commands = 31 | isort --check . 32 | ruff check 33 | pylint gitrevise tests {posargs} 34 | basepython = python3.10 35 | 36 | [testenv:format] 37 | runner = uv-venv-lock-runner 38 | description = validate formatting 39 | commands = ruff format --check {posargs} 40 | basepython = python3.10 41 | 42 | [gh-actions] 43 | python = 44 | 3.8: py38 45 | 3.9: py39 46 | 3.10: py310, mypy, lint, format 47 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Running Tests 5 | ------------- 6 | 7 | :command:`tox` is used to run tests. It will run :command:`mypy` for type 8 | checking, :command:`isort` and :command:`pylint` for linting, :command:`pytest` 9 | for testing, and :command:`black` for code formatting. 10 | 11 | .. code-block:: shell 12 | 13 | $ uv run tox # All python versions 14 | $ uv run tox -e py38 # Python 3.8 15 | $ uv run tox -e py39 # Python 3.9 16 | $ uv run tox -e py310 # Python 3.10 17 | 18 | $ uv run tox -e mypy # Mypy Typechecking 19 | $ uv run tox -e lint # Linting 20 | $ uv run tox -e format # Check Formatting 21 | 22 | Code Formatting 23 | --------------- 24 | 25 | This project uses ``isort`` and ``black`` for code formatting. 26 | 27 | .. code-block:: shell 28 | 29 | $ uv run isort . # sort imports 30 | $ uv run ruff format # format all python code 31 | 32 | Building Documentation 33 | ---------------------- 34 | 35 | Documentation is built using :command:`sphinx`. 36 | 37 | .. code-block:: shell 38 | 39 | $ cd docs/ 40 | $ make man # Build manpage 41 | 42 | Publishing 43 | ---------- 44 | 45 | .. code-block:: shell 46 | 47 | $ uv build 48 | $ uv run twine check dist/* 49 | $ uv run twine upload dist/* 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git revise 2 | [![Build Status](https://travis-ci.org/mystor/git-revise.svg?branch=master)](https://travis-ci.org/mystor/git-revise) 3 | [![PyPi](https://img.shields.io/pypi/v/git-revise.svg)](https://pypi.org/project/git-revise) 4 | [![Documentation Status](https://readthedocs.org/projects/git-revise/badge/?version=latest)](https://git-revise.readthedocs.io/en/latest/?badge=latest) 5 | 6 | 7 | `git revise` is a `git` subcommand to efficiently update, split, and rearrange 8 | commits. It is heavily inspired by `git rebase`, however it tries to be more 9 | efficient and ergonomic for patch-stack oriented workflows. 10 | 11 | By default, `git revise` will apply staged changes to a target commit, then 12 | update `HEAD` to point at the revised history. It also supports splitting 13 | commits and rewording commit messages. 14 | 15 | Unlike `git rebase`, `git revise` avoids modifying the working directory or 16 | the index state, performing all merges in-memory and only writing them when 17 | necessary. This allows it to be significantly faster on large codebases and 18 | avoids unnecessarily invalidating builds. 19 | 20 | ## Install 21 | 22 | ```sh 23 | $ pip install --user git-revise 24 | ``` 25 | 26 | Various people have also packaged `git revise` for platform-specific package 27 | managers (Thanks!) 28 | 29 | #### macOS Homebrew 30 | 31 | ```sh 32 | $ brew install git-revise 33 | ``` 34 | 35 | #### Fedora 36 | 37 | ```sh 38 | $ dnf install git-revise 39 | ``` 40 | 41 | ## Documentation 42 | 43 | Documentation, including usage and examples, is hosted on [Read the Docs]. 44 | 45 | [Read the Docs]: https://git-revise.readthedocs.io/en/latest 46 | 47 | -------------------------------------------------------------------------------- /tests/test_reword.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | 5 | from gitrevise.odb import Repository 6 | 7 | from .conftest import bash, editor_main, main 8 | 9 | 10 | @pytest.mark.parametrize("target", ["HEAD", "HEAD~", "HEAD~~"]) 11 | @pytest.mark.parametrize("use_editor", [True, False]) 12 | def test_reword(repo: Repository, target: str, use_editor: bool) -> None: 13 | bash( 14 | """ 15 | echo "hello, world" > file1 16 | git add file1 17 | git commit -m "commit 1" 18 | echo "new line!" >> file1 19 | git add file1 20 | git commit -m "commit 2" 21 | echo "yet another line!" >> file1 22 | git add file1 23 | git commit -m "commit 3" 24 | """ 25 | ) 26 | 27 | message = textwrap.dedent( 28 | """\ 29 | reword test 30 | 31 | another line 32 | """ 33 | ).encode() 34 | 35 | old = repo.get_commit(target) 36 | assert old.message != message 37 | assert old.persisted 38 | 39 | if use_editor: 40 | with editor_main(["--no-index", "-e", target]) as ed: 41 | with ed.next_file() as f: 42 | assert f.startswith(old.message) 43 | f.replace_dedent(message) 44 | else: 45 | main(["--no-index", "-m", "reword test", "-m", "another line", target]) 46 | 47 | new = repo.get_commit(target) 48 | assert old != new, "commit was modified" 49 | assert old.tree() == new.tree(), "tree is unchanged" 50 | assert old.parents() == new.parents(), "parents are unchanged" 51 | 52 | assert new.message == message, "message set correctly" 53 | assert new.persisted, "commit persisted to disk" 54 | assert new.author == old.author, "author is unchanged" 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.0 4 | 5 | * Add support for `git-rerere`, to record and replay conflict resolutions (#75) 6 | * Fix autosquash order of multiple fixup commits with the same target (#72) 7 | * Use `GIT_SEQUENCE_EDITOR` instead of `SEQUENCE_EDITOR` (#71) 8 | * Fix handling of multiline commit subjects (#86) 9 | * Add support for interactively revising or autosquashing the root commit via `--root` 10 | * Add support for `commit.gpgSign` (#46) 11 | * Improved support for git-for-windows (#112) 12 | 13 | ## v0.6.0 14 | 15 | * Fixed handling of fixup-of-fixup commits (#58) 16 | * Added support for `git add`'s `--patch` flag (#61) 17 | * Manpage is now installed in `share/man/man1` instead of `man/man1` (#62) 18 | * Which patch failed to apply is now included in the conflict editor (#53) 19 | * Trailing whitespaces are no longer generated for empty comment lines (#50) 20 | * Use `sequence.editor` when editing `revise-todo` (#60) 21 | 22 | ## v0.5.1 23 | 24 | * Support non-ASCII branchnames. (#48) 25 | * LICENSE included in PyPi package. (#44) 26 | 27 | ## v0.5.0 28 | 29 | * Invoke `GIT_EDITOR` correctly when it includes quotes. 30 | * Use `sh` instead of `bash` to run `GIT_EDITOR`. 31 | * Added support for the `core.commentChar` config option. 32 | * Added the `revise.autoSquash` config option to imply `--autosquash` by 33 | default. 34 | * Added support for unambiguous abbreviated refs. 35 | 36 | ## v0.4.2 37 | 38 | * Fixes a bug where the tempdir path is set incorrectly when run from a 39 | subdirectory. 40 | 41 | ## v0.4.1 42 | 43 | * Improved the performance and UX for the `cut` command. 44 | 45 | ## v0.4.0 46 | 47 | * Support for combining `--interactive` and `--edit` commands to perform bulk 48 | commit message editing during interactive mode. 49 | * No longer eagerly parses author/committer signatures, avoiding crashes when 50 | encountering broken signatures. 51 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | -------------------------------------------------------------------------------- /tests/test_cut.py: -------------------------------------------------------------------------------- 1 | from gitrevise.odb import Repository 2 | 3 | from .conftest import bash, editor_main 4 | 5 | 6 | def test_cut(repo: Repository) -> None: 7 | bash( 8 | """ 9 | echo "Hello, World" >> file1 10 | git add file1 11 | git commit -m "commit 1" 12 | 13 | echo "Append f1" >> file1 14 | echo "Make f2" >> file2 15 | git add file1 file2 16 | git commit -m "commit 2" 17 | 18 | echo "Append f3" >> file2 19 | git add file2 20 | git commit -m "commit 3" 21 | """ 22 | ) 23 | 24 | prev = repo.get_commit("HEAD") 25 | prev_u = prev.parent() 26 | prev_uu = prev_u.parent() 27 | 28 | with editor_main(["--cut", "HEAD~"], input=b"y\nn\n") as ed: 29 | with ed.next_file() as f: 30 | assert f.startswith_dedent("[1] commit 2\n") 31 | f.replace_dedent("part 1\n") 32 | 33 | with ed.next_file() as f: 34 | assert f.startswith_dedent("[2] commit 2\n") 35 | f.replace_dedent("part 2\n") 36 | 37 | new = repo.get_commit("HEAD") 38 | new_u2 = new.parent() 39 | new_u1 = new_u2.parent() 40 | new_uu = new_u1.parent() 41 | 42 | assert prev != new 43 | assert prev.message == new.message 44 | assert new_u2.message == b"part 2\n" 45 | assert new_u1.message == b"part 1\n" 46 | assert new_uu == prev_uu 47 | 48 | 49 | def test_cut_root(repo: Repository) -> None: 50 | bash( 51 | """ 52 | echo "Hello, World" >> file1 53 | echo "Make f2" >> file2 54 | git add file1 file2 55 | git commit -m "root commit" 56 | """ 57 | ) 58 | 59 | prev = repo.get_commit("HEAD") 60 | assert prev.is_root 61 | assert len(prev.parent_oids) == 0 62 | 63 | with editor_main(["--cut", "HEAD"], input=b"y\nn\n") as ed: 64 | with ed.next_file() as f: 65 | assert f.startswith_dedent("[1] root commit\n") 66 | f.replace_dedent("part 1\n") 67 | 68 | with ed.next_file() as f: 69 | assert f.startswith_dedent("[2] root commit\n") 70 | f.replace_dedent("part 2\n") 71 | 72 | new = repo.get_commit("HEAD") 73 | assert new != prev 74 | assert new.message == b"part 2\n" 75 | 76 | assert not new.is_root 77 | assert len(new.parent_oids) == 1 78 | 79 | new_u = new.parent() 80 | assert new_u.message == b"part 1\n" 81 | assert new_u.is_root 82 | assert len(new_u.parent_oids) == 0 83 | assert new_u.parent_tree() == prev.parent_tree() 84 | 85 | assert new_u != new 86 | assert new_u != prev 87 | -------------------------------------------------------------------------------- /docs/performance.rst: -------------------------------------------------------------------------------- 1 | Performance 2 | =========== 3 | 4 | .. note:: 5 | These numbers are from an earlier version, and may not reflect 6 | the current state of `git-revise`. 7 | 8 | With large repositories such as ``mozilla-central``, :command:`git revise` is 9 | often significantly faster than :manpage:`git-rebase(1)` for incremental, due 10 | to not needing to update the index or working directory during rebases. 11 | 12 | I did a simple test, applying a single-line change to a commit 11 patches up 13 | the stack. The following are my extremely non-scientific time measurements: 14 | 15 | ============================== ========= 16 | Command Real Time 17 | ============================== ========= 18 | ``git rebase -i --autosquash`` 16.931s 19 | ``git revise`` 0.541s 20 | ============================== ========= 21 | 22 | The following are the commands I ran: 23 | 24 | .. code-block:: bash 25 | 26 | # Apply changes with git rebase -i --autosquash 27 | $ git reset 6fceb7da316d && git add . 28 | $ time bash -c 'TARGET=14f1c85bf60d; git commit --fixup=$TARGET; EDITOR=true git rebase -i --autosquash $TARGET~' 29 | 30 | 31 | real 0m16.931s 32 | user 0m15.289s 33 | sys 0m3.579s 34 | 35 | # Apply changes with git revise 36 | $ git reset 6fceb7da316d && git add . 37 | $ time git revise 14f1c85bf60d 38 | 39 | 40 | real 0m0.541s 41 | user 0m0.354s 42 | sys 0m0.150s 43 | 44 | 45 | How is it faster? 46 | ----------------- 47 | 48 | .. rubric:: In-Memory Cache 49 | 50 | To avoid spawning unnecessary subprocesses and hitting disk too frequently, 51 | :command:`git revise` uses an in-memory cache of objects in the ODB which it 52 | has already seen. 53 | 54 | Intermediate git trees, blobs, and commits created during processing are held 55 | exclusively in-memory, and only persisted when necessary. 56 | 57 | 58 | .. rubric:: Custom Merge Algorithm 59 | 60 | A custom implementation of the merge algorithm is used which directly merges 61 | trees rather than using the index. This ends up being faster on large 62 | repositories, as only the subset of modified files and directories need to be 63 | examined when merging. 64 | 65 | .. note:: 66 | Currently this algorithm is incapable of handling copy and rename 67 | operations correctly, instead treating them as file creation and deletion 68 | actions. This may be resolveable in the future. 69 | 70 | .. rubric:: Avoiding Index & Working Directory 71 | 72 | The working directory and index are never examined or updated during the 73 | rebasing process, avoiding disk I/O and invalidating existing builds. 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "git-revise" 7 | dynamic = ["version"] 8 | requires-python = ">=3.8" 9 | authors = [{ name = "Nika Layzell", email = "nika@thelayzells.com" }] 10 | description = "Efficiently update, split, and rearrange git commits" 11 | readme = "README.md" 12 | license = { file = "LICENSE" } 13 | keywords = ["git", "revise", "rebase", "amend", "fixup"] 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "Environment :: Console", 18 | "Topic :: Software Development :: Version Control", 19 | "Topic :: Software Development :: Version Control :: Git", 20 | "License :: OSI Approved :: MIT License", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | ] 26 | 27 | [project.scripts] 28 | git-revise = "gitrevise.tui:main" 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "isort~=5.13.2", 33 | "mypy~=1.14.1", 34 | "pylint~=3.2.7", 35 | "pytest-xdist~=3.6.1", 36 | "pytest~=8.3.4", 37 | "ruff~=0.9.6", 38 | "sphinx~=7.1.2", 39 | "tox-uv~=1.13.1", 40 | "tox~=4.24.1", 41 | "twine~=6.1.0", 42 | "typing-extensions~=4.12.2", 43 | ] 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/mystor/git-revise/" 47 | Issues = "https://github.com/mystor/git-revise/issues/" 48 | Repository = "https://github.com/mystor/git-revise/" 49 | Documentation = "https://git-revise.readthedocs.io/en/latest/" 50 | 51 | [tool.hatch.version] 52 | path = "gitrevise/__init__.py" 53 | 54 | [tool.hatch.build.targets.wheel] 55 | packages = ["/gitrevise"] 56 | 57 | [tool.hatch.build.targets.wheel.shared-data] 58 | "git-revise.1" = "share/man/man1/git-revise.1" 59 | 60 | [tool.hatch.build.targets.sdist] 61 | include = ["/gitrevise"] 62 | 63 | [tool.pylint.messages_control] 64 | disable = [ 65 | "missing-docstring", 66 | "too-few-public-methods", 67 | "too-many-arguments", 68 | "too-many-branches", 69 | "too-many-instance-attributes", 70 | "too-many-return-statements", 71 | "cyclic-import", 72 | "fixme", 73 | 74 | # Currently broken analyses which are also handled (better) by mypy 75 | "class-variable-slots-conflict", 76 | "no-member", 77 | ] 78 | 79 | good-names = [ 80 | # "Exception as e" is perfectly fine. 81 | "e", 82 | # "with open(…) as f" is idiomatic. 83 | "f", 84 | # Other contextually-unambiguous names. 85 | "fn", 86 | "repo", 87 | "ed", 88 | ] 89 | 90 | # TODO(https://github.com/astral-sh/ruff/issues/14813): Remove this once ruff properly respects 91 | # requires-python. 92 | [tool.ruff] 93 | -------------------------------------------------------------------------------- /tests/test_gpgsign.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from subprocess import CalledProcessError 4 | 5 | import pytest 6 | 7 | from gitrevise.odb import Repository 8 | from gitrevise.utils import sh_run 9 | 10 | from .conftest import bash, main 11 | 12 | 13 | def test_gpgsign( 14 | repo: Repository, 15 | short_tmpdir: Path, 16 | monkeypatch: pytest.MonkeyPatch, 17 | ) -> None: 18 | bash("git commit --allow-empty -m 'commit 1'") 19 | assert repo.get_commit("HEAD").gpgsig is None 20 | 21 | # On MacOS, pytest's temp paths are too long for gpg-agent. 22 | # See https://github.com/pytest-dev/pytest/issues/5802 23 | gnupghome = short_tmpdir 24 | 25 | # On windows, convert the path to git-bash's unix format before writing it 26 | # into the environment, as the gpg binary reading it is run under mingw. 27 | if os.name == "nt": 28 | proc = sh_run( 29 | ["cygpath", "-u", str(gnupghome)], 30 | capture_output=True, 31 | check=True, 32 | ) 33 | monkeypatch.setenv("GNUPGHOME", proc.stdout.decode().strip()) 34 | else: 35 | monkeypatch.setenv("GNUPGHOME", str(gnupghome)) 36 | 37 | gnupghome.chmod(0o700) 38 | (gnupghome / "gpg.conf").write_text("pinentry-mode loopback") 39 | user_ident = repo.default_author.signing_key 40 | sh_run( 41 | ["gpg", "--batch", "--passphrase", "", "--quick-gen-key", user_ident], 42 | check=True, 43 | ) 44 | 45 | bash("git config commit.gpgSign true") 46 | main(["HEAD"]) 47 | assert repo.get_commit("HEAD").gpgsig is not None, ( 48 | "git config commit.gpgSign activates GPG signing" 49 | ) 50 | 51 | bash("git config revise.gpgSign false") 52 | main(["HEAD"]) 53 | assert repo.get_commit("HEAD").gpgsig is None, ( 54 | "git config revise.gpgSign overrides commit.gpgSign" 55 | ) 56 | 57 | main(["HEAD", "--gpg-sign"]) 58 | assert repo.get_commit("HEAD").gpgsig is not None, ( 59 | "commandline option overrides configuration" 60 | ) 61 | 62 | main(["HEAD", "--no-gpg-sign"]) 63 | assert repo.get_commit("HEAD").gpgsig is None, "long option" 64 | 65 | main(["HEAD", "-S"]) 66 | assert repo.get_commit("HEAD").gpgsig is not None, "short option" 67 | 68 | bash("git config gpg.program false") 69 | try: 70 | main(["HEAD", "--gpg-sign"]) 71 | assert False, "Overridden gpg.program should fail" 72 | except CalledProcessError: 73 | pass 74 | bash("git config --unset gpg.program") 75 | 76 | # Check that we can sign multiple commits. 77 | bash( 78 | """ 79 | git -c commit.gpgSign=false commit --allow-empty -m 'commit 2' 80 | git -c commit.gpgSign=false commit --allow-empty -m 'commit 3' 81 | git -c commit.gpgSign=false commit --allow-empty -m 'commit 4' 82 | """ 83 | ) 84 | main(["HEAD~~", "--gpg-sign"]) 85 | assert repo.get_commit("HEAD~~").gpgsig is not None 86 | assert repo.get_commit("HEAD~").gpgsig is not None 87 | assert repo.get_commit("HEAD").gpgsig is not None 88 | 89 | # Check that we can remove signatures from multiple commits. 90 | main(["HEAD~", "--no-gpg-sign"]) 91 | assert repo.get_commit("HEAD~").gpgsig is None 92 | assert repo.get_commit("HEAD").gpgsig is None 93 | 94 | # Check that we add signatures, even if the target commit already has one. 95 | assert repo.get_commit("HEAD~~").gpgsig is not None 96 | main(["HEAD~~", "--gpg-sign"]) 97 | assert repo.get_commit("HEAD~").gpgsig is not None 98 | assert repo.get_commit("HEAD").gpgsig is not None 99 | -------------------------------------------------------------------------------- /tests/test_cleanup_editor_content.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from gitrevise.utils import cleanup_editor_content, EditorCleanupMode, GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR 3 | 4 | 5 | def test_strip_comments() -> None: 6 | _do_test( 7 | ( 8 | b"foo\n" 9 | b"# bar\n" 10 | ), 11 | expected_strip=b"foo\n", 12 | expected_whitespace=( 13 | b"foo\n" 14 | b"# bar\n" 15 | ) 16 | ) 17 | 18 | 19 | def test_leading_empty_lines() -> None: 20 | _do_test( 21 | ( 22 | b"\n" 23 | b"\n" 24 | b"foo\n" 25 | b"# bar\n" 26 | ), 27 | expected_strip=( 28 | b"foo\n" 29 | ), 30 | expected_whitespace=( 31 | b"foo\n" 32 | b"# bar\n" 33 | ) 34 | ) 35 | 36 | 37 | def test_trailing_empty_lines() -> None: 38 | _do_test( 39 | ( 40 | b"foo\n" 41 | b"# bar\n" 42 | b"\n" 43 | b"\n" 44 | ), 45 | expected_strip=b"foo\n", 46 | expected_whitespace=( 47 | b"foo\n" 48 | b"# bar\n" 49 | ) 50 | ) 51 | 52 | 53 | def test_trailing_whitespaces() -> None: 54 | _do_test( 55 | ( 56 | b"foo \n" 57 | b"foo \n" 58 | b"# bar \n" 59 | ), 60 | expected_strip=( 61 | b"foo\n" 62 | b"foo\n" 63 | ), 64 | expected_whitespace=( 65 | b"foo\n" 66 | b"foo\n" 67 | b"# bar\n" 68 | ) 69 | ) 70 | 71 | 72 | def test_consecutive_emtpy_lines() -> None: 73 | _do_test( 74 | ( 75 | b"foo\n" 76 | b"" 77 | b"" 78 | b"bar\n" 79 | ), 80 | expected_strip=( 81 | b"foo\n" 82 | b"" 83 | b"bar\n" 84 | ) 85 | ) 86 | 87 | 88 | def test_scissors() -> None: 89 | original = ("foo\n" 90 | f"# {GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR}" 91 | "bar\n").encode() 92 | _do_test( 93 | original, 94 | expected_strip=( 95 | b"foo\n" 96 | b"bar\n" 97 | ), 98 | expected_whitespace=original, 99 | expected_scissors=b"foo\n" 100 | ) 101 | 102 | 103 | def test_force_cut_scissors_in_verbatim_mode() -> None: 104 | actual = cleanup_editor_content( 105 | ( 106 | "foo\n" 107 | f"# {GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR}" 108 | "bar\n" 109 | ).encode(), 110 | b"#", 111 | EditorCleanupMode.VERBATIM, 112 | force_cut_after_scissors=True 113 | ) 114 | assert actual == b"foo\n" 115 | 116 | 117 | def _do_test(data: bytes, expected_strip: bytes, expected_whitespace: Optional[bytes] = None, 118 | expected_scissors: Optional[bytes] = None): 119 | if expected_whitespace is None: 120 | expected_whitespace = expected_strip 121 | if expected_scissors is None: 122 | expected_scissors = expected_whitespace 123 | 124 | actual_strip = cleanup_editor_content(data, b"#", EditorCleanupMode.STRIP) 125 | actual_verbatim = cleanup_editor_content(data, b"#", EditorCleanupMode.VERBATIM) 126 | actual_scissors = cleanup_editor_content(data, b"#", EditorCleanupMode.SCISSORS) 127 | actual_whitespace = cleanup_editor_content(data, b"#", EditorCleanupMode.WHITESPACE) 128 | 129 | assert actual_strip == expected_strip, "default" 130 | assert actual_verbatim == data, "verbatim" 131 | assert actual_scissors == expected_scissors, "scissors" 132 | assert actual_whitespace == expected_whitespace, "whitespace" 133 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | 15 | import os 16 | import sys 17 | 18 | from sphinx.application import Sphinx 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | import gitrevise 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = "git-revise" 27 | copyright = "2018-2022, Nika Layzell" 28 | author = "Nika Layzell " 29 | 30 | # The short X.Y version 31 | version = gitrevise.__version__ 32 | # The full version, including alpha/beta/rc tags 33 | release = version 34 | 35 | 36 | # -- General configuration --------------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | # 40 | # needs_sphinx = '1.0' 41 | 42 | # Add any Sphinx extension module names here, as strings. They can be 43 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 44 | # ones. 45 | extensions = ["sphinx.ext.autodoc"] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | # templates_path = ['_templates'] 49 | 50 | # The suffix(es) of source filenames. 51 | # You can specify multiple suffix as a list of string: 52 | # 53 | # source_suffix = ['.rst', '.md'] 54 | source_suffix = ".rst" 55 | 56 | # The master toctree document. 57 | master_doc = "index" 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | # 62 | # This is also used if you do content translation via gettext catalogs. 63 | # Usually you set "language" from the command line for these cases. 64 | language = "en" 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | # This pattern also affects html_static_path and html_extra_path . 69 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 70 | 71 | # The name of the Pygments (syntax highlighting) style to use. 72 | pygments_style = "sphinx" 73 | 74 | 75 | # -- Options for HTML output ------------------------------------------------- 76 | 77 | # The theme to use for HTML and HTML Help pages. See the documentation for 78 | # a list of builtin themes. 79 | # 80 | html_theme = "sphinx_rtd_theme" 81 | 82 | # Theme options are theme-specific and customize the look and feel of a theme 83 | # further. For a list of options available for each theme, see the 84 | # documentation. 85 | # 86 | # html_theme_options = {} 87 | 88 | # Add any paths that contain custom static files (such as style sheets) here, 89 | # relative to this directory. They are copied after the builtin static files, 90 | # so a file named "default.css" will overwrite the builtin "default.css". 91 | # html_static_path = ['_static'] 92 | 93 | # Custom sidebar templates, must be a dictionary that maps document names 94 | # to template names. 95 | # 96 | # The default sidebars (for documents that don't match any pattern) are 97 | # defined by theme itself. Builtin themes are using these templates by 98 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 99 | # 'searchbox.html']``. 100 | # 101 | # html_sidebars = {} 102 | 103 | manpages_url = "https://manpages.debian.org/{path}" 104 | 105 | 106 | # Manpages links fail to be handled correctly in the version of sphinx used by 107 | # Read the Docs -- disable the HTML5 writer. 108 | html_experimental_html5_writer = False 109 | 110 | # -- Options for manual page output ------------------------------------------ 111 | 112 | # One entry per manual page. List of tuples 113 | # (source start file, name, description, authors, manual section). 114 | man_pages = [ 115 | ( 116 | "man", 117 | "git-revise", 118 | "Efficiently update, split, and rearrange git commits", 119 | None, 120 | 1, 121 | ) 122 | ] 123 | 124 | # -- Extension configuration ------------------------------------------------- 125 | 126 | 127 | def setup(app: Sphinx) -> None: 128 | app.add_object_type("gitconfig", "gitconfig", objname="git config value") 129 | -------------------------------------------------------------------------------- /gitrevise/tui.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from argparse import ArgumentParser, Namespace 5 | from subprocess import CalledProcessError 6 | from typing import List, Optional 7 | 8 | from . import __version__ 9 | from .merge import MergeConflict 10 | from .odb import Commit, Reference, Repository 11 | from .todo import apply_todos, autosquash_todos, build_todos, edit_todos 12 | from .utils import ( 13 | EditorError, 14 | commit_range, 15 | cut_commit, 16 | edit_commit_message, 17 | local_commits, 18 | update_head, 19 | ) 20 | 21 | 22 | def build_parser() -> ArgumentParser: 23 | parser = ArgumentParser( 24 | description="""\ 25 | Rebase staged changes onto the given commit, and rewrite history to 26 | incorporate these changes.""" 27 | ) 28 | target_group = parser.add_mutually_exclusive_group() 29 | target_group.add_argument( 30 | "--root", 31 | action="store_true", 32 | help="revise starting at the root commit", 33 | ) 34 | target_group.add_argument( 35 | "target", 36 | nargs="?", 37 | help="target commit to apply fixups to", 38 | ) 39 | parser.add_argument("--ref", default="HEAD", help="reference to update") 40 | parser.add_argument( 41 | "--reauthor", 42 | action="store_true", 43 | help="reset the author of the targeted commit", 44 | ) 45 | parser.add_argument("--version", action="version", version=__version__) 46 | parser.add_argument( 47 | "--edit", 48 | "-e", 49 | action="store_true", 50 | help="edit commit message of targeted commit(s)", 51 | ) 52 | 53 | autosquash_group = parser.add_mutually_exclusive_group() 54 | autosquash_group.add_argument( 55 | "--autosquash", 56 | action="store_true", 57 | help="automatically apply fixup! and squash! commits to their targets", 58 | ) 59 | autosquash_group.add_argument( 60 | "--no-autosquash", 61 | action="store_true", 62 | help="force disable revise.autoSquash behaviour", 63 | ) 64 | 65 | index_group = parser.add_mutually_exclusive_group() 66 | index_group.add_argument( 67 | "--no-index", 68 | action="store_true", 69 | help="ignore the index while rewriting history", 70 | ) 71 | index_group.add_argument( 72 | "--all", 73 | "-a", 74 | action="store_true", 75 | help="stage all tracked files before running", 76 | ) 77 | index_group.add_argument( 78 | "--patch", 79 | "-p", 80 | action="store_true", 81 | help="interactively stage hunks before running", 82 | ) 83 | 84 | mode_group = parser.add_mutually_exclusive_group() 85 | mode_group.add_argument( 86 | "--interactive", 87 | "-i", 88 | action="store_true", 89 | help="interactively edit commit stack", 90 | ) 91 | mode_group.add_argument( 92 | "--message", 93 | "-m", 94 | action="append", 95 | help="specify commit message on command line", 96 | ) 97 | mode_group.add_argument( 98 | "--cut", 99 | "-c", 100 | action="store_true", 101 | help="interactively cut a commit into two smaller commits", 102 | ) 103 | 104 | gpg_group = parser.add_mutually_exclusive_group() 105 | gpg_group.add_argument( 106 | "--gpg-sign", 107 | "-S", 108 | action="store_true", 109 | help="GPG sign commits", 110 | ) 111 | gpg_group.add_argument( 112 | "--no-gpg-sign", 113 | action="store_true", 114 | help="do not GPG sign commits", 115 | ) 116 | return parser 117 | 118 | 119 | def interactive( 120 | args: Namespace, repo: Repository, staged: Optional[Commit], head: Reference[Commit] 121 | ) -> None: 122 | assert head.target is not None 123 | 124 | if args.target or args.root: 125 | base = repo.get_commit(args.target) if args.target else None 126 | to_rebase = commit_range(base, head.target) 127 | else: 128 | base, to_rebase = local_commits(repo, head.target) 129 | 130 | # Build up an initial todos list, edit that todos list. 131 | todos = original = build_todos(to_rebase, staged) 132 | 133 | if enable_autosquash(args, repo): 134 | todos = autosquash_todos(todos) 135 | 136 | if args.interactive: 137 | todos = edit_todos(repo, todos, msgedit=args.edit) 138 | 139 | if todos != original: 140 | # Perform the todo list actions. 141 | new_head = apply_todos(base, todos, reauthor=args.reauthor) 142 | 143 | # Update the value of HEAD to the new state. 144 | update_head(head, new_head, None) 145 | else: 146 | print("(warning) no changes performed", file=sys.stderr) 147 | 148 | 149 | def enable_autosquash(args: Namespace, repo: Repository) -> bool: 150 | if args.autosquash: 151 | return True 152 | if args.no_autosquash: 153 | return False 154 | 155 | return repo.bool_config( 156 | "revise.autoSquash", 157 | default=repo.bool_config("rebase.autoSquash", default=False), 158 | ) 159 | 160 | 161 | def noninteractive( 162 | args: Namespace, repo: Repository, staged: Optional[Commit], head: Reference[Commit] 163 | ) -> None: 164 | assert head.target is not None 165 | 166 | if args.root: 167 | raise ValueError( 168 | "Incompatible option: " 169 | "--root may only be used with --autosquash or --interactive" 170 | ) 171 | 172 | if args.target is None: 173 | raise ValueError(" is a required argument") 174 | 175 | head = repo.get_commit_ref(args.ref) 176 | if head.target is None: 177 | raise ValueError("Invalid target reference") 178 | 179 | current = replaced = repo.get_commit(args.target) 180 | to_rebase = commit_range(current, head.target) 181 | 182 | # Apply changes to the target commit. 183 | final = head.target.tree() 184 | if staged: 185 | print(f"Applying staged changes to '{args.target}'") 186 | current = current.update(tree=staged.rebase(current).tree()) 187 | final = staged.rebase(head.target).tree() 188 | 189 | # Update the commit message on the target commit if requested. 190 | if args.message: 191 | message = b"\n".join(line.encode("utf-8") + b"\n" for line in args.message) 192 | current = current.update(message=message) 193 | 194 | # Prompt the user to edit the commit message if requested. 195 | if args.edit: 196 | current = edit_commit_message(current) 197 | 198 | # Rewrite the author to match the current user if requested. 199 | if args.reauthor: 200 | current = current.update(author=repo.default_author) 201 | 202 | # If the commit should be cut, prompt the user to perform the cut. 203 | if args.cut: 204 | current = cut_commit(current) 205 | 206 | # Add or remove GPG signatures. 207 | if repo.sign_commits != bool(current.gpgsig): 208 | current = current.update(recommit=True) 209 | change_signature = any( 210 | repo.sign_commits != bool(commit.gpgsig) for commit in to_rebase 211 | ) 212 | 213 | if current != replaced or change_signature: 214 | print(f"{current.oid.short()} {current.summary()}") 215 | 216 | # Rebase commits atop the commit range. 217 | for commit in to_rebase: 218 | if repo.sign_commits != bool(commit.gpgsig): 219 | commit = commit.update(recommit=True) 220 | current = commit.rebase(current) 221 | print(f"{current.oid.short()} {current.summary()}") 222 | 223 | update_head(head, current, final) 224 | else: 225 | print("(warning) no changes performed", file=sys.stderr) 226 | 227 | 228 | def inner_main(args: Namespace, repo: Repository) -> None: 229 | # If '-a' or '-p' was specified, stage changes. 230 | # Note that stdout=None means "inherit current stdout". 231 | if args.all: 232 | repo.git("add", "-u", stdout=None) 233 | if args.patch: 234 | repo.git("add", "-p", stdout=None) 235 | 236 | if args.gpg_sign: 237 | repo.sign_commits = True 238 | if args.no_gpg_sign: 239 | repo.sign_commits = False 240 | 241 | # Create a commit with changes from the index 242 | staged = None 243 | if not args.no_index: 244 | staged = repo.index.commit(message=b"") 245 | if staged.tree() == staged.parent_tree(): 246 | staged = None # No changes, ignore the commit 247 | 248 | # Determine the HEAD reference which we're going to update. 249 | head = repo.get_commit_ref(args.ref) 250 | if head.target is None: 251 | raise ValueError("Head reference not found!") 252 | 253 | # Either enter the interactive or non-interactive codepath. 254 | if args.interactive or args.autosquash: 255 | interactive(args, repo, staged, head) 256 | else: 257 | noninteractive(args, repo, staged, head) 258 | 259 | 260 | def main(argv: Optional[List[str]] = None) -> None: 261 | args = build_parser().parse_args(argv) 262 | try: 263 | with Repository() as repo: 264 | inner_main(args, repo) 265 | except CalledProcessError as err: 266 | print(f"subprocess exited with non-zero status: {err.returncode}") 267 | sys.exit(1) 268 | except EditorError as err: 269 | print(f"editor error: {err}") 270 | sys.exit(1) 271 | except MergeConflict as err: 272 | print(f"merge conflict: {err}") 273 | sys.exit(1) 274 | except ValueError as err: 275 | print(f"invalid value: {err}") 276 | sys.exit(1) 277 | -------------------------------------------------------------------------------- /docs/man.rst: -------------------------------------------------------------------------------- 1 | .. _git_revise: 2 | 3 | ========================================================================= 4 | ``git-revise(1)`` -- Efficiently update, split, and rearrange git commits 5 | ========================================================================= 6 | 7 | .. program:: git revise: 8 | 9 | SYNOPSIS 10 | ======== 11 | 12 | *git revise* [] [] 13 | 14 | DESCRIPTION 15 | =========== 16 | 17 | :program:`git revise` is a :manpage:`git(1)` subcommand to efficiently 18 | update, split, and rearrange commits. It is heavily inspired by 19 | :manpage:`git-rebase(1)`, however tries to be more efficient and ergonomic for 20 | patch-stack oriented workflows. 21 | 22 | By default, :program:`git revise` will apply staged changes to , 23 | updating ``HEAD`` to point at the revised history. It also supports splitting 24 | commits, rewording commit messages. 25 | 26 | Unlike :manpage:`git-rebase(1)`, :program:`git revise` avoids modifying 27 | working directory and index state, performing all merges in-memory, and only 28 | writing them when necessary. This allows it to be significantly faster on 29 | large codebases, and avoid invalidating builds. 30 | 31 | If :option:`--autosquash` or :option:`--interactive` is specified, the 32 | argument may be omitted or given as the special value `:option:--root`. 33 | If it is omitted, :program:`git revise` will consider a range of unpublished 34 | commits on the current branch. If given as `:option:--root`, all commits 35 | including the root commit will be considered. 36 | 37 | OPTIONS 38 | ======= 39 | 40 | General options 41 | --------------- 42 | 43 | .. option:: -a, --all 44 | 45 | Stage changes to tracked files before revising. 46 | 47 | .. option:: -p, --patch 48 | 49 | Interactively stage hunks from the worktree before revising. 50 | 51 | .. option:: --no-index 52 | 53 | Ignore staged changes in the index. 54 | 55 | .. option:: --reauthor 56 | 57 | Reset target commit's author to the current user. 58 | 59 | .. option:: --ref 60 | 61 | Working branch to update; defaults to ``HEAD``. 62 | 63 | .. option:: -S, --gpg-sign, --no-gpg-sign 64 | 65 | GPG-sign commits. Overrides both the ``commit.gpgSign`` and 66 | ``revise.gpgSign`` git configurations. 67 | 68 | Main modes of operation 69 | ----------------------- 70 | 71 | .. option:: -i, --interactive 72 | 73 | Rather than applying staged changes to , edit a todo list of 74 | actions to perform on commits after . See :ref:`interactive-mode`. 75 | 76 | .. option:: --autosquash, --no-autosquash 77 | 78 | Rather than directly applying staged changes to , automatically 79 | perform fixup or squash actions marked with ``fixup!`` or ``squash!`` 80 | between and the current ``HEAD``. For more information on what 81 | these actions do, see :ref:`interactive-mode`. 82 | 83 | These commits are usually created with ``git commit --fixup=`` or 84 | ``git commit --squash=``, and identify the target with the first 85 | line of its commit message. 86 | 87 | This option can be combined with :option:`--interactive` to modify the 88 | generated todos before they're executed. 89 | 90 | If the :option:`--autosquash` option is enabled by default using a 91 | configuration variable, the option :option:`--no-autosquash` can be used 92 | to override and disable this setting. See :ref:`configuration`. 93 | 94 | .. option:: -c, --cut 95 | 96 | Interactively select hunks from . The chosen hunks are split into 97 | a second commit immediately after the target. 98 | 99 | After splitting is complete, both commits' messages are edited. 100 | 101 | See the "Interactive Mode" section of :manpage:`git-add(1)` to learn how 102 | to operate this mode. 103 | 104 | .. option:: -e, --edit 105 | 106 | After applying staged changes, edit 's commit message. 107 | 108 | This option can be combined with :option:`--interactive` to allow editing 109 | of commit messages within the todo list. For more information on, see 110 | :ref:`interactive-mode`. 111 | 112 | .. option:: -m , --message 113 | 114 | Use the given as the new commit message for . If multiple 115 | :option:`-m` options are given, their values are concatenated as separate 116 | paragraphs. 117 | 118 | .. option:: --version 119 | 120 | Print version information and exit. 121 | 122 | 123 | .. _configuration: 124 | 125 | CONFIGURATION 126 | ============= 127 | 128 | Configuration is managed by :manpage:`git-config(1)`. 129 | 130 | .. gitconfig:: revise.autoSquash 131 | 132 | If set to true, imply :option:`--autosquash` whenever :option:`--interactive` 133 | is specified. Overridden by :option:`--no-autosquash`. Defaults to false. If 134 | not set, the value of ``rebase.autoSquash`` is used instead. 135 | 136 | .. gitconfig:: revise.gpgSign 137 | 138 | If set to true, GPG-sign new commits; defaults to false. This setting 139 | overrides the original git configuration ``commit.gpgSign`` and may be 140 | overridden by the command line options ``--gpg-sign`` and 141 | ``--no-gpg-sign``. 142 | 143 | 144 | CONFLICT RESOLUTION 145 | =================== 146 | 147 | When a conflict is encountered, :command:`git revise` will attempt to resolve 148 | it automatically using standard git mechanisms. If automatic resolution 149 | fails, the user will be prompted to resolve them manually. 150 | 151 | There is currently no support for using :manpage:`git-mergetool(1)` to 152 | resolve conflicts. 153 | 154 | No attempt is made to detect renames of files or directories. :command:`git 155 | revise` may produce suboptimal results across renames. Use the interactive 156 | mode of :manpage:`git-rebase(1)` when rename tracking is important. 157 | 158 | 159 | NOTES 160 | ===== 161 | 162 | A successful :command:`git revise` will add a single entry to the reflog, 163 | allowing it to be undone with ``git reset @{1}``. Unsuccessful :command:`git 164 | revise` commands will leave your repository largely unmodified. 165 | 166 | No merge commits may occur between the target commit and ``HEAD``, as 167 | rewriting them is not supported. 168 | 169 | See :manpage:`git-rebase(1)` for more information on the implications of 170 | modifying history on a repository that you share. 171 | 172 | 173 | .. _interactive-mode: 174 | 175 | INTERACTIVE MODE 176 | ================ 177 | 178 | :command:`git revise` supports an interactive mode inspired by the 179 | interactive mode of :manpage:`git-rebase(1)`. 180 | 181 | This mode is started with the last commit you want to retain "as-is": 182 | 183 | .. code-block:: bash 184 | 185 | git revise -i 186 | 187 | The special target `--root` is available to revise everything up to the root 188 | commit: 189 | 190 | .. code-block:: bash 191 | 192 | git revise -i --root 193 | 194 | An editor will be fired up with the commits in your current branch after the 195 | given commit. If the index has any staged but uncommitted changes, a ```` entry will also be present. 197 | 198 | .. code-block:: none 199 | 200 | pick 8338dfa88912 Oneline summary of first commit 201 | pick 735609912343 Summary of second commit 202 | index 672841329981 203 | 204 | These commits may be re-ordered to change the order they appear in history. 205 | In addition, the ``pick`` and ``index`` commands may be replaced to modify 206 | their behaviour. If present, ``index`` commands must be at the bottom of the 207 | list, i.e. they can not be followed by non-index commands. 208 | 209 | If :option:`-e` was specified, the full commit message will be included, and 210 | each command line will begin with a ``++``. Any changes made to the commit 211 | messages in this file will be applied to the commit in question, allowing for 212 | simultaneous editing of commit messages during the todo editing phase. 213 | 214 | .. code-block:: none 215 | 216 | ++ pick 8338dfa88912 217 | Oneline summary of first commit 218 | 219 | Body of first commit 220 | 221 | ++ pick 735609912343 222 | Summary of second commit 223 | 224 | Body of second commit 225 | 226 | ++ index 672841329981 227 | 228 | 229 | The following commands are supported in all interactive modes: 230 | 231 | .. describe:: index 232 | 233 | Do not commit these changes, instead leaving them staged in the index. 234 | Index lines must come last in the file. 235 | 236 | .. note: 237 | Commits may not be deleted or dropped from the to-do list. To remove a 238 | commit, mark it as an index action, and use :manpage:`git-reset(1)` to 239 | discard staged changes. 240 | 241 | .. describe:: pick 242 | 243 | Use the given commit as-is in history. When applied to the generated 244 | ``index`` entry, the commit will have the message ````. 245 | 246 | .. describe:: squash 247 | 248 | Add the commit's changes into the previous commit and open an editor 249 | to merge the commits' messages. 250 | 251 | .. describe:: fixup 252 | 253 | Like squash, but discard this commit's message rather than editing. 254 | 255 | .. describe:: reword 256 | 257 | Open an editor to modify the commit message. 258 | 259 | .. describe:: cut 260 | 261 | Interactively select hunks from the commit. The chosen hunks are split 262 | into a second commit immediately after it. 263 | 264 | After splitting is complete, both commits' messages are edited. 265 | 266 | See the "Interactive Mode" section of :manpage:`git-add(1)` to learn how 267 | to operate this mode. 268 | 269 | 270 | REPORTING BUGS 271 | ============== 272 | 273 | Please report issues and feature requests to the issue tracker at 274 | https://github.com/mystor/git-revise/issues. 275 | 276 | Code, documentation and other contributions are also welcomed. 277 | 278 | 279 | SEE ALSO 280 | ======== 281 | 282 | :manpage:`git(1)` 283 | :manpage:`git-rebase(1)` 284 | :manpage:`git-add(1)` 285 | -------------------------------------------------------------------------------- /gitrevise/todo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from enum import Enum 5 | from typing import List, Optional 6 | 7 | from .odb import Commit, MissingObject, Repository 8 | from .utils import cut_commit, edit_commit_message, run_editor, run_sequence_editor 9 | 10 | 11 | class StepKind(Enum): 12 | PICK = "pick" 13 | FIXUP = "fixup" 14 | SQUASH = "squash" 15 | REWORD = "reword" 16 | CUT = "cut" 17 | INDEX = "index" 18 | 19 | def __str__(self) -> str: 20 | return str(self.value) 21 | 22 | @staticmethod 23 | def parse(instr: str) -> StepKind: 24 | if "pick".startswith(instr): 25 | return StepKind.PICK 26 | if "fixup".startswith(instr): 27 | return StepKind.FIXUP 28 | if "squash".startswith(instr): 29 | return StepKind.SQUASH 30 | if "reword".startswith(instr): 31 | return StepKind.REWORD 32 | if "cut".startswith(instr): 33 | return StepKind.CUT 34 | if "index".startswith(instr): 35 | return StepKind.INDEX 36 | raise ValueError( 37 | f"step kind '{instr}' must be one of: pick, fixup, squash, reword, cut, or index" 38 | ) 39 | 40 | 41 | class Step: 42 | kind: StepKind 43 | commit: Commit 44 | message: Optional[bytes] 45 | 46 | def __init__(self, kind: StepKind, commit: Commit) -> None: 47 | self.kind = kind 48 | self.commit = commit 49 | self.message = None 50 | 51 | @staticmethod 52 | def parse(repo: Repository, instr: str) -> Step: 53 | parsed = re.match(r"(?P\S+)\s+(?P\S+)", instr) 54 | if not parsed: 55 | raise ValueError( 56 | f"todo entry '{instr}' must follow format " 57 | ) 58 | kind = StepKind.parse(parsed.group("command")) 59 | commit = repo.get_commit(parsed.group("hash")) 60 | return Step(kind, commit) 61 | 62 | def __str__(self) -> str: 63 | return f"{self.kind} {self.commit.oid.short()}" 64 | 65 | def __eq__(self, other: object) -> bool: 66 | if not isinstance(other, Step): 67 | return False 68 | return ( 69 | self.kind == other.kind 70 | and self.commit == other.commit 71 | and self.message == other.message 72 | ) 73 | 74 | 75 | def build_todos(commits: List[Commit], index: Optional[Commit]) -> List[Step]: 76 | steps = [Step(StepKind.PICK, commit) for commit in commits] 77 | if index: 78 | steps.append(Step(StepKind.INDEX, index)) 79 | return steps 80 | 81 | 82 | def validate_todos(old: List[Step], new: List[Step]) -> None: 83 | """Raise an exception if the new todo list is malformed compared to the 84 | original todo list""" 85 | old_set = set(o.commit.oid for o in old) 86 | new_set = set(n.commit.oid for n in new) 87 | 88 | assert len(old_set) == len(old), "Unexpected duplicate original commit!" 89 | if len(new_set) != len(new): 90 | # XXX(nika): Perhaps print which commits are duplicates? 91 | raise ValueError("Unexpected duplicate commit found in todos") 92 | 93 | if new_set - old_set: 94 | # XXX(nika): Perhaps print which commits were found? 95 | raise ValueError("Unexpected commits not referenced in original TODO list") 96 | 97 | if old_set - new_set: 98 | # XXX(nika): Perhaps print which commits were omitted? 99 | raise ValueError("Unexpected commits missing from TODO list") 100 | 101 | saw_index = False 102 | for step in new: 103 | if step.kind == StepKind.INDEX: 104 | saw_index = True 105 | elif saw_index: 106 | raise ValueError("'index' actions follow all non-index todo items") 107 | 108 | 109 | def add_autosquash_step(step: Step, picks: List[List[Step]]) -> None: 110 | needle = summary = step.commit.summary() 111 | while needle.startswith("fixup! ") or needle.startswith("squash! "): 112 | needle = needle.split(maxsplit=1)[1] 113 | 114 | if needle != summary: 115 | if summary.startswith("fixup!"): 116 | new_step = Step(StepKind.FIXUP, step.commit) 117 | else: 118 | assert summary.startswith("squash!") 119 | new_step = Step(StepKind.SQUASH, step.commit) 120 | 121 | for seq in picks: 122 | if seq[0].commit.summary().startswith(needle): 123 | seq.append(new_step) 124 | return 125 | 126 | try: 127 | target = step.commit.repo.get_commit(needle) 128 | for seq in picks: 129 | if any(s.commit == target for s in seq): 130 | seq.append(new_step) 131 | return 132 | except (ValueError, MissingObject): 133 | pass 134 | 135 | picks.append([step]) 136 | 137 | 138 | def autosquash_todos(todos: List[Step]) -> List[Step]: 139 | picks: List[List[Step]] = [] 140 | for step in todos: 141 | add_autosquash_step(step, picks) 142 | return [s for p in picks for s in p] 143 | 144 | 145 | def edit_todos_msgedit(repo: Repository, todos: List[Step]) -> List[Step]: 146 | todos_text = b"" 147 | for step in todos: 148 | todos_text += f"++ {step}\n".encode() 149 | todos_text += step.commit.message + b"\n" 150 | 151 | # Invoke the editors to parse commit messages. 152 | response = run_editor( 153 | repo, 154 | "git-revise-todo", 155 | todos_text, 156 | comments=f"""\ 157 | Interactive Revise Todos({len(todos)} commands) 158 | 159 | Commands: 160 | p, pick = use commit 161 | r, reword = use commit, but edit the commit message 162 | s, squash = use commit, but meld into previous commit 163 | f, fixup = like squash, but discard this commit's message 164 | c, cut = interactively split commit into two smaller commits 165 | i, index = leave commit changes staged, but uncommitted 166 | 167 | Each command block is prefixed by a '++' marker, followed by the command to 168 | run, the commit hash and after a newline the complete commit message until 169 | the next '++' marker or the end of the file. 170 | 171 | Commit messages will be reworded to match the provided message before the 172 | command is performed. 173 | 174 | These blocks are executed from top to bottom. They can be re-ordered and 175 | their commands can be changed, however the number of blocks must remain 176 | identical. If present, index blocks must be at the bottom of the list, 177 | i.e. they can not be followed by non-index blocks. 178 | 179 | 180 | If you remove everything, the revising process will be aborted. 181 | """, 182 | ) 183 | 184 | # Parse the response back into a list of steps 185 | result = [] 186 | for full in re.split(rb"^\+\+ ", response, flags=re.M)[1:]: 187 | cmd, message = full.split(b"\n", maxsplit=1) 188 | 189 | step = Step.parse(repo, cmd.decode(errors="replace").strip()) 190 | step.message = message.strip() + b"\n" 191 | result.append(step) 192 | 193 | validate_todos(todos, result) 194 | 195 | return result 196 | 197 | 198 | def edit_todos( 199 | repo: Repository, todos: List[Step], msgedit: bool = False 200 | ) -> List[Step]: 201 | if msgedit: 202 | return edit_todos_msgedit(repo, todos) 203 | 204 | todos_text = b"" 205 | for step in todos: 206 | todos_text += f"{step} {step.commit.summary()}\n".encode() 207 | 208 | response = run_sequence_editor( 209 | repo, 210 | "git-revise-todo", 211 | todos_text, 212 | comments=f"""\ 213 | Interactive Revise Todos ({len(todos)} commands) 214 | 215 | Commands: 216 | p, pick = use commit 217 | r, reword = use commit, but edit the commit message 218 | s, squash = use commit, but meld into previous commit 219 | f, fixup = like squash, but discard this commit's log message 220 | c, cut = interactively split commit into two smaller commits 221 | i, index = leave commit changes staged, but uncommitted 222 | 223 | These lines are executed from top to bottom. They can be re-ordered and 224 | their commands can be changed, however the number of lines must remain 225 | identical. If present, index lines must be at the bottom of the list, 226 | i.e. they can not be followed by non-index lines. 227 | 228 | If you remove everything, the revising process will be aborted. 229 | """, 230 | ) 231 | 232 | # Parse the response back into a list of steps 233 | result = [] 234 | for line in response.splitlines(): 235 | if line.isspace(): 236 | continue 237 | step = Step.parse(repo, line.decode(errors="replace").strip()) 238 | result.append(step) 239 | 240 | validate_todos(todos, result) 241 | 242 | return result 243 | 244 | 245 | def apply_todos( 246 | current: Optional[Commit], 247 | todos: List[Step], 248 | reauthor: bool = False, 249 | ) -> Commit: 250 | for step in todos: 251 | rebased = step.commit.rebase(current).update(message=step.message) 252 | if step.kind == StepKind.PICK: 253 | current = rebased 254 | elif step.kind == StepKind.FIXUP: 255 | if current is None: 256 | raise ValueError("Cannot apply fixup as first commit") 257 | current = current.update(tree=rebased.tree()) 258 | elif step.kind == StepKind.REWORD: 259 | current = edit_commit_message(rebased) 260 | elif step.kind == StepKind.SQUASH: 261 | if current is None: 262 | raise ValueError("Cannot apply squash as first commit") 263 | fused = current.message + b"\n\n" + rebased.message 264 | current = current.update(tree=rebased.tree(), message=fused) 265 | current = edit_commit_message(current) 266 | elif step.kind == StepKind.CUT: 267 | current = cut_commit(rebased) 268 | elif step.kind == StepKind.INDEX: 269 | break 270 | else: 271 | raise ValueError(f"Unknown StepKind value: {step.kind}") 272 | 273 | if reauthor: 274 | current = current.update(author=current.repo.default_author) 275 | 276 | print(f"{step.kind.value:6} {current.oid.short()} {current.summary()}") 277 | 278 | if current is None: 279 | raise ValueError("No commits introduced on top of root commit") 280 | 281 | return current 282 | -------------------------------------------------------------------------------- /git-revise.1: -------------------------------------------------------------------------------- 1 | .\" Man page generated from reStructuredText. 2 | . 3 | .TH "GIT-REVISE" "1" "Jan 05, 2022" "0.7.0" "git-revise" 4 | .SH NAME 5 | git-revise \- Efficiently update, split, and rearrange git commits 6 | . 7 | .nr rst2man-indent-level 0 8 | . 9 | .de1 rstReportMargin 10 | \\$1 \\n[an-margin] 11 | level \\n[rst2man-indent-level] 12 | level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] 13 | - 14 | \\n[rst2man-indent0] 15 | \\n[rst2man-indent1] 16 | \\n[rst2man-indent2] 17 | .. 18 | .de1 INDENT 19 | .\" .rstReportMargin pre: 20 | . RS \\$1 21 | . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] 22 | . nr rst2man-indent-level +1 23 | .\" .rstReportMargin post: 24 | .. 25 | .de UNINDENT 26 | . RE 27 | .\" indent \\n[an-margin] 28 | .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] 29 | .nr rst2man-indent-level -1 30 | .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] 31 | .in \\n[rst2man-indent\\n[rst2man-indent-level]]u 32 | .. 33 | .SH SYNOPSIS 34 | .sp 35 | \fIgit revise\fP [] [] 36 | .SH DESCRIPTION 37 | .sp 38 | \fBgit revise\fP is a \fBgit(1)\fP subcommand to efficiently 39 | update, split, and rearrange commits. It is heavily inspired by 40 | \fBgit\-rebase(1)\fP, however tries to be more efficient and ergonomic for 41 | patch\-stack oriented workflows. 42 | .sp 43 | By default, \fBgit revise\fP will apply staged changes to , 44 | updating \fBHEAD\fP to point at the revised history. It also supports splitting 45 | commits, rewording commit messages. 46 | .sp 47 | Unlike \fBgit\-rebase(1)\fP, \fBgit revise\fP avoids modifying 48 | working directory and index state, performing all merges in\-memory, and only 49 | writing them when necessary. This allows it to be significantly faster on 50 | large codebases, and avoid invalidating builds. 51 | .sp 52 | If \fI\%\-\-autosquash\fP or \fI\%\-\-interactive\fP is specified, the 53 | argument may be omitted or given as the special value \fI:option:\-\-root\fP\&. 54 | If it is omitted, \fBgit revise\fP will consider a range of unpublished 55 | commits on the current branch. If given as \fI:option:\-\-root\fP, all commits 56 | including the root commit will be considered. 57 | .SH OPTIONS 58 | .SS General options 59 | .INDENT 0.0 60 | .TP 61 | .B \-a, \-\-all 62 | Stage changes to tracked files before revising. 63 | .UNINDENT 64 | .INDENT 0.0 65 | .TP 66 | .B \-p, \-\-patch 67 | Interactively stage hunks from the worktree before revising. 68 | .UNINDENT 69 | .INDENT 0.0 70 | .TP 71 | .B \-\-no\-index 72 | Ignore staged changes in the index. 73 | .UNINDENT 74 | .INDENT 0.0 75 | .TP 76 | .B \-\-reauthor 77 | Reset target commit\(aqs author to the current user. 78 | .UNINDENT 79 | .INDENT 0.0 80 | .TP 81 | .B \-\-ref 82 | Working branch to update; defaults to \fBHEAD\fP\&. 83 | .UNINDENT 84 | .INDENT 0.0 85 | .TP 86 | .B \-S, \-\-gpg\-sign, \-\-no\-gpg\-sign 87 | GPG\-sign commits. Overrides both the \fBcommit.gpgSign\fP and 88 | \fBrevise.gpgSign\fP git configurations. 89 | .UNINDENT 90 | .SS Main modes of operation 91 | .INDENT 0.0 92 | .TP 93 | .B \-i, \-\-interactive 94 | Rather than applying staged changes to , edit a todo list of 95 | actions to perform on commits after . See \fI\%INTERACTIVE MODE\fP\&. 96 | .UNINDENT 97 | .INDENT 0.0 98 | .TP 99 | .B \-\-autosquash, \-\-no\-autosquash 100 | Rather than directly applying staged changes to , automatically 101 | perform fixup or squash actions marked with \fBfixup!\fP or \fBsquash!\fP 102 | between and the current \fBHEAD\fP\&. For more information on what 103 | these actions do, see \fI\%INTERACTIVE MODE\fP\&. 104 | .sp 105 | These commits are usually created with \fBgit commit \-\-fixup=\fP or 106 | \fBgit commit \-\-squash=\fP, and identify the target with the first 107 | line of its commit message. 108 | .sp 109 | This option can be combined with \fI\%\-\-interactive\fP to modify the 110 | generated todos before they\(aqre executed. 111 | .sp 112 | If the \fI\%\-\-autosquash\fP option is enabled by default using a 113 | configuration variable, the option \fI\%\-\-no\-autosquash\fP can be used 114 | to override and disable this setting. See \fI\%CONFIGURATION\fP\&. 115 | .UNINDENT 116 | .INDENT 0.0 117 | .TP 118 | .B \-c, \-\-cut 119 | Interactively select hunks from . The chosen hunks are split into 120 | a second commit immediately after the target. 121 | .sp 122 | After splitting is complete, both commits\(aq messages are edited. 123 | .sp 124 | See the "Interactive Mode" section of \fBgit\-add(1)\fP to learn how 125 | to operate this mode. 126 | .UNINDENT 127 | .INDENT 0.0 128 | .TP 129 | .B \-e, \-\-edit 130 | After applying staged changes, edit \(aqs commit message. 131 | .sp 132 | This option can be combined with \fI\%\-\-interactive\fP to allow editing 133 | of commit messages within the todo list. For more information on, see 134 | \fI\%INTERACTIVE MODE\fP\&. 135 | .UNINDENT 136 | .INDENT 0.0 137 | .TP 138 | .B \-m , \-\-message 139 | Use the given as the new commit message for . If multiple 140 | \fI\%\-m\fP options are given, their values are concatenated as separate 141 | paragraphs. 142 | .UNINDENT 143 | .INDENT 0.0 144 | .TP 145 | .B \-\-version 146 | Print version information and exit. 147 | .UNINDENT 148 | .SH CONFIGURATION 149 | .sp 150 | Configuration is managed by \fBgit\-config(1)\fP\&. 151 | .INDENT 0.0 152 | .TP 153 | .B revise.autoSquash 154 | If set to true, imply \fI\%\-\-autosquash\fP whenever \fI\%\-\-interactive\fP 155 | is specified. Overridden by \fI\%\-\-no\-autosquash\fP\&. Defaults to false. If 156 | not set, the value of \fBrebase.autoSquash\fP is used instead. 157 | .UNINDENT 158 | .INDENT 0.0 159 | .TP 160 | .B revise.gpgSign 161 | If set to true, GPG\-sign new commits; defaults to false. This setting 162 | overrides the original git configuration \fBcommit.gpgSign\fP and may be 163 | overridden by the command line options \fB\-\-gpg\-sign\fP and 164 | \fB\-\-no\-gpg\-sign\fP\&. 165 | .UNINDENT 166 | .SH CONFLICT RESOLUTION 167 | .sp 168 | When a conflict is encountered, \fBgit revise\fP will attempt to resolve 169 | it automatically using standard git mechanisms. If automatic resolution 170 | fails, the user will be prompted to resolve them manually. 171 | .sp 172 | There is currently no support for using \fBgit\-mergetool(1)\fP to 173 | resolve conflicts. 174 | .sp 175 | No attempt is made to detect renames of files or directories. \fBgit 176 | revise\fP may produce suboptimal results across renames. Use the interactive 177 | mode of \fBgit\-rebase(1)\fP when rename tracking is important. 178 | .SH NOTES 179 | .sp 180 | A successful \fBgit revise\fP will add a single entry to the reflog, 181 | allowing it to be undone with \fBgit reset @{1}\fP\&. Unsuccessful \fBgit 182 | revise\fP commands will leave your repository largely unmodified. 183 | .sp 184 | No merge commits may occur between the target commit and \fBHEAD\fP, as 185 | rewriting them is not supported. 186 | .sp 187 | See \fBgit\-rebase(1)\fP for more information on the implications of 188 | modifying history on a repository that you share. 189 | .SH INTERACTIVE MODE 190 | .sp 191 | \fBgit revise\fP supports an interactive mode inspired by the 192 | interactive mode of \fBgit\-rebase(1)\fP\&. 193 | .sp 194 | This mode is started with the last commit you want to retain "as\-is": 195 | .INDENT 0.0 196 | .INDENT 3.5 197 | .sp 198 | .nf 199 | .ft C 200 | git revise \-i 201 | .ft P 202 | .fi 203 | .UNINDENT 204 | .UNINDENT 205 | .sp 206 | The special target \fI\-\-root\fP is available to revise everything up to the root 207 | commit: 208 | .INDENT 0.0 209 | .INDENT 3.5 210 | .sp 211 | .nf 212 | .ft C 213 | git revise \-i \-\-root 214 | .ft P 215 | .fi 216 | .UNINDENT 217 | .UNINDENT 218 | .sp 219 | An editor will be fired up with the commits in your current branch after the 220 | given commit. If the index has any staged but uncommitted changes, a \fB\fP entry will also be present. 222 | .INDENT 0.0 223 | .INDENT 3.5 224 | .sp 225 | .nf 226 | .ft C 227 | pick 8338dfa88912 Oneline summary of first commit 228 | pick 735609912343 Summary of second commit 229 | index 672841329981 230 | .ft P 231 | .fi 232 | .UNINDENT 233 | .UNINDENT 234 | .sp 235 | These commits may be re\-ordered to change the order they appear in history. 236 | In addition, the \fBpick\fP and \fBindex\fP commands may be replaced to modify 237 | their behaviour. If present, \fBindex\fP commands must be at the bottom of the 238 | list, i.e. they can not be followed by non\-index commands. 239 | .sp 240 | If \fI\%\-e\fP was specified, the full commit message will be included, and 241 | each command line will begin with a \fB++\fP\&. Any changes made to the commit 242 | messages in this file will be applied to the commit in question, allowing for 243 | simultaneous editing of commit messages during the todo editing phase. 244 | .INDENT 0.0 245 | .INDENT 3.5 246 | .sp 247 | .nf 248 | .ft C 249 | ++ pick 8338dfa88912 250 | Oneline summary of first commit 251 | 252 | Body of first commit 253 | 254 | ++ pick 735609912343 255 | Summary of second commit 256 | 257 | Body of second commit 258 | 259 | ++ index 672841329981 260 | 261 | .ft P 262 | .fi 263 | .UNINDENT 264 | .UNINDENT 265 | .sp 266 | The following commands are supported in all interactive modes: 267 | .INDENT 0.0 268 | .TP 269 | .B index 270 | Do not commit these changes, instead leaving them staged in the index. 271 | Index lines must come last in the file. 272 | .UNINDENT 273 | .INDENT 0.0 274 | .TP 275 | .B pick 276 | Use the given commit as\-is in history. When applied to the generated 277 | \fBindex\fP entry, the commit will have the message \fB\fP\&. 278 | .UNINDENT 279 | .INDENT 0.0 280 | .TP 281 | .B squash 282 | Add the commit\(aqs changes into the previous commit and open an editor 283 | to merge the commits\(aq messages. 284 | .UNINDENT 285 | .INDENT 0.0 286 | .TP 287 | .B fixup 288 | Like squash, but discard this commit\(aqs message rather than editing. 289 | .UNINDENT 290 | .INDENT 0.0 291 | .TP 292 | .B reword 293 | Open an editor to modify the commit message. 294 | .UNINDENT 295 | .INDENT 0.0 296 | .TP 297 | .B cut 298 | Interactively select hunks from the commit. The chosen hunks are split 299 | into a second commit immediately after it. 300 | .sp 301 | After splitting is complete, both commits\(aq messages are edited. 302 | .sp 303 | See the "Interactive Mode" section of \fBgit\-add(1)\fP to learn how 304 | to operate this mode. 305 | .UNINDENT 306 | .SH REPORTING BUGS 307 | .sp 308 | Please report issues and feature requests to the issue tracker at 309 | \fI\%https://github.com/mystor/git\-revise/issues\fP\&. 310 | .sp 311 | Code, documentation and other contributions are also welcomed. 312 | .SH SEE ALSO 313 | .sp 314 | \fBgit(1)\fP 315 | \fBgit\-rebase(1)\fP 316 | \fBgit\-add(1)\fP 317 | .SH COPYRIGHT 318 | 2018-2022, Nika Layzell 319 | .\" Generated by docutils manpage writer. 320 | . 321 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | import subprocess 4 | import sys 5 | import tempfile 6 | import textwrap 7 | import traceback 8 | from concurrent.futures import CancelledError, Future 9 | from concurrent.futures.thread import ThreadPoolExecutor 10 | from contextlib import contextmanager 11 | from http.server import BaseHTTPRequestHandler, HTTPServer 12 | from pathlib import Path 13 | from queue import Empty, Queue 14 | from threading import Thread 15 | from types import TracebackType 16 | from typing import TYPE_CHECKING, Generator, Optional, Sequence, Type, Union 17 | 18 | import pytest 19 | 20 | from gitrevise.odb import Repository 21 | from gitrevise.utils import sh_path 22 | 23 | from . import dummy_editor 24 | 25 | if TYPE_CHECKING: 26 | from typing import Any, Tuple 27 | 28 | from _typeshed import StrPath 29 | 30 | 31 | @pytest.fixture(name="hermetic_seal", autouse=True) 32 | def fixture_hermetic_seal( 33 | tmp_path_factory: pytest.TempPathFactory, 34 | monkeypatch: pytest.MonkeyPatch, 35 | ) -> None: 36 | # Lock down user git configuration 37 | home = tmp_path_factory.mktemp("home") 38 | xdg_config_home = home / ".config" 39 | if os.name == "nt": 40 | monkeypatch.setenv("USERPROFILE", str(home)) 41 | monkeypatch.setenv("HOME", str(home)) 42 | monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_home)) 43 | monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "true") 44 | 45 | # Lock down commit/authoring time 46 | monkeypatch.setenv("GIT_AUTHOR_DATE", "1500000000 -0500") 47 | monkeypatch.setenv("GIT_COMMITTER_DATE", "1500000000 -0500") 48 | 49 | # Install known configuration 50 | gitconfig = home / ".gitconfig" 51 | gitconfig.write_bytes( 52 | textwrap.dedent( 53 | """\ 54 | [core] 55 | eol = lf 56 | autocrlf = input 57 | [user] 58 | email = test@example.com 59 | name = Test User 60 | """ 61 | ).encode() 62 | ) 63 | 64 | # If we are not expecting an editor to be launched, abort immediately. 65 | # (The `false` command always exits with failure). 66 | # This is overridden in editor_main. 67 | monkeypatch.setenv("GIT_EDITOR", "false") 68 | 69 | # Switch into a test workdir, and init our repo 70 | workdir = tmp_path_factory.mktemp("workdir") 71 | monkeypatch.chdir(workdir) 72 | bash("git init -q") 73 | 74 | 75 | @pytest.fixture(name="repo") 76 | # pylint: disable=unused-argument 77 | def fixture_repo(hermetic_seal: None) -> Generator[Repository, None, None]: 78 | with Repository() as repo: 79 | yield repo 80 | 81 | 82 | @pytest.fixture(name="short_tmpdir") 83 | def fixture_short_tmpdir() -> Generator[Path, None, None]: 84 | with tempfile.TemporaryDirectory() as tdir: 85 | yield Path(tdir) 86 | 87 | 88 | def bash(command: str) -> None: 89 | # Use a custom environment for bash commands so commits with those commands 90 | # have unique names and emails. 91 | env = dict( 92 | os.environ, 93 | GIT_AUTHOR_NAME="Bash Author", 94 | GIT_AUTHOR_EMAIL="bash_author@example.com", 95 | GIT_COMMITTER_NAME="Bash Committer", 96 | GIT_COMMITTER_EMAIL="bash_committer@example.com", 97 | ) 98 | subprocess.run([sh_path(), "-ec", textwrap.dedent(command)], check=True, env=env) 99 | 100 | 101 | def changeline(path: "StrPath", lineno: int, newline: bytes) -> None: 102 | with open(path, "rb") as f: 103 | lines = f.readlines() 104 | lines[lineno] = newline 105 | with open(path, "wb") as f: 106 | f.write(b"".join(lines)) 107 | 108 | 109 | # Run the main entry point for git-revise in a subprocess. 110 | def main( 111 | args: Sequence[str], 112 | cwd: Optional["StrPath"] = None, 113 | # pylint: disable=redefined-builtin 114 | input: Optional[bytes] = None, 115 | check: bool = True, 116 | ) -> "subprocess.CompletedProcess[bytes]": 117 | cmd = [sys.executable, "-m", "gitrevise", *args] 118 | print("Running", cmd, {"cwd": cwd, "input": input, "check": check}) 119 | return subprocess.run(cmd, cwd=cwd, input=input, check=check) 120 | 121 | 122 | @contextmanager 123 | def editor_main( 124 | args: Sequence[str], 125 | cwd: Optional["StrPath"] = None, 126 | # pylint: disable=redefined-builtin 127 | input: Optional[bytes] = None, 128 | ) -> "Generator[Editor, None, subprocess.CompletedProcess[bytes]]": 129 | with pytest.MonkeyPatch().context() as monkeypatch, Editor() as ed, ThreadPoolExecutor() as tpe: 130 | host, port = ed.server_address 131 | host = host.decode() if isinstance(host, (bytes, bytearray)) else host 132 | editor_cmd = " ".join( 133 | shlex.quote(p) 134 | for p in ( 135 | sys.executable, 136 | dummy_editor.__file__, 137 | f"http://{host}:{port}/", 138 | ) 139 | ) 140 | monkeypatch.setenv("GIT_EDITOR", editor_cmd) 141 | 142 | # Run the command asynchronously. 143 | future = tpe.submit(main, args, cwd=cwd, input=input) 144 | 145 | # If it fails, cancel anything waiting on `ed.next_file()`. 146 | def cancel_on_error(future: "Future[Any]") -> None: 147 | exc = future.exception() 148 | if exc: 149 | ed.cancel_all_pending_edits(exc) 150 | 151 | future.add_done_callback(cancel_on_error) 152 | 153 | # Yield the editor, so that tests can process incoming requests via `ed.next_file()`. 154 | try: 155 | yield ed 156 | except GeneratorExit: 157 | pass 158 | 159 | return future.result() 160 | 161 | 162 | class EditorFile: 163 | indata: bytes 164 | outdata: Optional[bytes] 165 | 166 | def __init__(self, indata: bytes) -> None: 167 | self.indata = indata 168 | self.outdata = None 169 | 170 | def startswith(self, text: bytes) -> bool: 171 | return self.indata.startswith(text) 172 | 173 | def startswith_dedent(self, text: str) -> bool: 174 | return self.startswith(textwrap.dedent(text).encode()) 175 | 176 | def equals(self, text: bytes) -> bool: 177 | return self.indata == text 178 | 179 | def equals_dedent(self, text: str) -> bool: 180 | return self.equals(textwrap.dedent(text).encode()) 181 | 182 | def replace_dedent(self, text: Union[str, bytes]) -> None: 183 | if isinstance(text, str): 184 | text = textwrap.dedent(text).encode() 185 | self.outdata = text 186 | 187 | def __repr__(self) -> str: 188 | return f"" 189 | 190 | 191 | class EditorFileRequestHandler(BaseHTTPRequestHandler): 192 | server: "Editor" 193 | 194 | # pylint: disable=invalid-name 195 | def do_POST(self) -> None: 196 | content_length = self.headers.get("content-length") 197 | length = int(content_length) if content_length is not None else 0 198 | in_data = self.rfile.read(length) 199 | 200 | status, out_data = 500, b"no traceback" 201 | try: 202 | # The request is ready. Tell our server, and wait for a reply. 203 | status, out_data = 200, self.server.await_edit(in_data) 204 | except Exception: # pylint: disable=broad-except 205 | status, out_data = 500, traceback.format_exc().encode() 206 | finally: 207 | self.send_response(status) 208 | self.send_header("content-length", str(len(out_data))) 209 | self.end_headers() 210 | self.wfile.write(out_data) 211 | 212 | 213 | class Editor(HTTPServer): 214 | pending_edits: "Queue[Tuple[bytes, Future[bytes]]]" 215 | handle_thread: Thread 216 | timeout: int 217 | 218 | def __init__(self) -> None: 219 | # Bind to a randomly-allocated free port. 220 | super().__init__(("127.0.0.1", 0), EditorFileRequestHandler) 221 | self.pending_edits = Queue() 222 | self.timeout = 10 223 | self.handle_thread = Thread( 224 | name="editor-server", 225 | target=lambda: self.serve_forever(poll_interval=0.01), 226 | ) 227 | 228 | def await_edit(self, in_data: bytes) -> bytes: 229 | """Enqueues an edit and then synchronously waits for it to be processed.""" 230 | result_future: "Future[bytes]" = Future() 231 | # Add the request to be picked up when the test calls `next_file`. 232 | self.pending_edits.put((in_data, result_future)) 233 | # Wait for the result and return it (or throw). 234 | return result_future.result(timeout=self.timeout) 235 | 236 | @contextmanager 237 | def next_file(self) -> Generator[EditorFile, None, None]: 238 | try: 239 | in_data, result_future = self.pending_edits.get(timeout=self.timeout) 240 | except Empty as e: 241 | raise RuntimeError("timeout while waiting for request") from e 242 | 243 | if result_future.done() or not result_future.set_running_or_notify_cancel(): 244 | raise result_future.exception() or CancelledError() 245 | 246 | try: 247 | editor_file = EditorFile(in_data) 248 | 249 | # Yield the request we received and were notified about. 250 | # The test can modify the contents. 251 | yield editor_file 252 | 253 | assert editor_file.outdata 254 | result_future.set_result(editor_file.outdata) 255 | except Exception as e: 256 | result_future.set_exception(e) 257 | raise 258 | finally: 259 | self.pending_edits.task_done() 260 | 261 | def cancel_all_pending_edits(self, exc: Optional[BaseException] = None) -> None: 262 | if self.handle_thread.is_alive(): 263 | self.shutdown() 264 | 265 | # Cancel all of the pending edit requests. 266 | while True: 267 | try: 268 | _body, task = self.pending_edits.get_nowait() 269 | except Empty: 270 | break 271 | if task.cancel(): 272 | self.pending_edits.task_done() 273 | 274 | # If there were no edit requests, the main test thread may be blocked on `next_file`. 275 | # Give that thread a canceled future to wake it up. 276 | canceled_future: "Future[bytes]" = Future() 277 | canceled_future.set_exception(exc or CancelledError()) 278 | self.pending_edits.put_nowait((b"cancelled", canceled_future)) 279 | 280 | def server_close(self) -> None: 281 | self.handle_thread.join() 282 | super().server_close() 283 | 284 | def __enter__(self) -> "Editor": 285 | super().__enter__() 286 | self.handle_thread.start() 287 | return self 288 | 289 | # The super class just defines this as *args. 290 | # pylint: disable=arguments-differ 291 | def __exit__( 292 | self, 293 | etype: Optional[Type[BaseException]], 294 | value: Optional[BaseException], 295 | tb: Optional[TracebackType], 296 | ) -> None: 297 | try: 298 | # Only assert if we're not already raising an exception. 299 | if etype is None: 300 | assert self.pending_edits.empty() 301 | finally: 302 | self.cancel_all_pending_edits(value) 303 | super().__exit__(etype, value, tb) 304 | 305 | 306 | __all__ = ( 307 | "bash", 308 | "changeline", 309 | "editor_main", 310 | "main", 311 | "Editor", 312 | ) 313 | -------------------------------------------------------------------------------- /tests/test_interactive.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, Sequence 2 | 3 | import pytest 4 | 5 | from gitrevise.odb import Repository 6 | 7 | from .conftest import bash, editor_main 8 | 9 | if TYPE_CHECKING: 10 | from _typeshed import StrPath 11 | 12 | 13 | def interactive_reorder_helper(repo: Repository, cwd: "StrPath") -> None: 14 | bash( 15 | """ 16 | echo "hello, world" > file1 17 | git add file1 18 | git commit -m "commit one" 19 | 20 | echo "second file" > file2 21 | git add file2 22 | git commit -m "commit two" 23 | 24 | echo "new line!" >> file1 25 | git add file1 26 | git commit -m "commit three" 27 | """ 28 | ) 29 | 30 | prev = repo.get_commit("HEAD") 31 | prev_u = prev.parent() 32 | prev_uu = prev_u.parent() 33 | 34 | with editor_main(["-i", "HEAD~~"], cwd=cwd) as ed: 35 | with ed.next_file() as f: 36 | assert f.startswith_dedent( 37 | f"""\ 38 | pick {prev.parent().oid.short()} commit two 39 | pick {prev.oid.short()} commit three 40 | """ 41 | ) 42 | f.replace_dedent( 43 | f"""\ 44 | pick {prev.oid.short()} commit three 45 | pick {prev.parent().oid.short()} commit two 46 | """ 47 | ) 48 | 49 | curr = repo.get_commit("HEAD") 50 | curr_u = curr.parent() 51 | curr_uu = curr_u.parent() 52 | 53 | assert curr != prev 54 | assert curr.tree() == prev.tree() 55 | assert curr_u.message == prev.message 56 | assert curr.message == prev_u.message 57 | assert curr_uu == prev_uu 58 | 59 | assert b"file2" in prev_u.tree().entries 60 | assert b"file2" not in curr_u.tree().entries 61 | 62 | assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] 63 | assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] 64 | assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] 65 | 66 | 67 | def test_interactive_reorder(repo: Repository) -> None: 68 | interactive_reorder_helper(repo, cwd=repo.workdir) 69 | 70 | 71 | def test_interactive_reorder_subdir(repo: Repository) -> None: 72 | bash("mkdir subdir") 73 | interactive_reorder_helper(repo, cwd=repo.workdir / "subdir") 74 | 75 | 76 | def test_interactive_on_root(repo: Repository) -> None: 77 | bash( 78 | """ 79 | echo "hello, world" > file1 80 | git add file1 81 | git commit -m "commit one" 82 | 83 | echo "second file" > file2 84 | git add file2 85 | git commit -m "commit two" 86 | 87 | echo "new line!" >> file1 88 | git add file1 89 | git commit -m "commit three" 90 | """ 91 | ) 92 | 93 | orig_commit3 = prev = repo.get_commit("HEAD") 94 | orig_commit2 = prev_u = prev.parent() 95 | orig_commit1 = prev_u.parent() 96 | 97 | index_tree = repo.index.tree() 98 | 99 | with editor_main(["-i", "--root"]) as ed: 100 | with ed.next_file() as f: 101 | assert f.startswith_dedent( 102 | f"""\ 103 | pick {prev.parent().parent().oid.short()} commit one 104 | pick {prev.parent().oid.short()} commit two 105 | pick {prev.oid.short()} commit three 106 | """ 107 | ) 108 | f.replace_dedent( 109 | f"""\ 110 | pick {prev.parent().oid.short()} commit two 111 | pick {prev.parent().parent().oid.short()} commit one 112 | pick {prev.oid.short()} commit three 113 | """ 114 | ) 115 | 116 | new_commit3 = curr = repo.get_commit("HEAD") 117 | new_commit1 = curr_u = curr.parent() 118 | new_commit2 = curr_u.parent() 119 | 120 | assert curr != prev 121 | assert curr.tree() == index_tree 122 | assert new_commit1.message == orig_commit1.message 123 | assert new_commit2.message == orig_commit2.message 124 | assert new_commit3.message == orig_commit3.message 125 | 126 | assert new_commit2.is_root 127 | assert new_commit1.parent() == new_commit2 128 | assert new_commit3.parent() == new_commit1 129 | 130 | assert new_commit1.tree().entries[b"file1"] == orig_commit1.tree().entries[b"file1"] 131 | assert new_commit2.tree().entries[b"file2"] == orig_commit2.tree().entries[b"file2"] 132 | assert new_commit3.tree() == orig_commit3.tree() 133 | 134 | 135 | def test_interactive_fixup(repo: Repository) -> None: 136 | bash( 137 | """ 138 | echo "hello, world" > file1 139 | git add file1 140 | git commit -m "commit one" 141 | 142 | echo "second file" > file2 143 | git add file2 144 | git commit -m "commit two" 145 | 146 | echo "new line!" >> file1 147 | git add file1 148 | git commit -m "commit three" 149 | 150 | echo "extra" >> file3 151 | git add file3 152 | """ 153 | ) 154 | 155 | prev = repo.get_commit("HEAD") 156 | prev_u = prev.parent() 157 | prev_uu = prev_u.parent() 158 | 159 | index_tree = repo.index.tree() 160 | 161 | with editor_main(["-i", "HEAD~~"]) as ed: 162 | with ed.next_file() as f: 163 | index = repo.index.commit() 164 | 165 | assert f.startswith_dedent( 166 | f"""\ 167 | pick {prev.parent().oid.short()} commit two 168 | pick {prev.oid.short()} commit three 169 | index {index.oid.short()} 170 | """ 171 | ) 172 | f.replace_dedent( 173 | f"""\ 174 | pick {prev.oid.short()} commit three 175 | fixup {index.oid.short()} 176 | pick {prev.parent().oid.short()} commit two 177 | """ 178 | ) 179 | 180 | curr = repo.get_commit("HEAD") 181 | curr_u = curr.parent() 182 | curr_uu = curr_u.parent() 183 | 184 | assert curr != prev 185 | assert curr.tree() == index_tree 186 | assert curr_u.message == prev.message 187 | assert curr.message == prev_u.message 188 | assert curr_uu == prev_uu 189 | 190 | assert b"file2" in prev_u.tree().entries 191 | assert b"file2" not in curr_u.tree().entries 192 | 193 | assert b"file3" not in prev.tree().entries 194 | assert b"file3" not in prev_u.tree().entries 195 | assert b"file3" not in prev_uu.tree().entries 196 | 197 | assert b"file3" in curr.tree().entries 198 | assert b"file3" in curr_u.tree().entries 199 | assert b"file3" not in curr_uu.tree().entries 200 | 201 | assert curr.tree().entries[b"file3"].blob().body == b"extra\n" 202 | assert curr_u.tree().entries[b"file3"].blob().body == b"extra\n" 203 | 204 | assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] 205 | assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] 206 | assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] 207 | 208 | 209 | @pytest.mark.parametrize( 210 | "rebase_config,revise_config,expected", 211 | [ 212 | (None, None, False), 213 | ("1", "0", False), 214 | ("0", "1", True), 215 | ("1", None, True), 216 | (None, "1", True), 217 | ], 218 | ) 219 | def test_autosquash_config( 220 | repo: Repository, 221 | rebase_config: Optional[str], 222 | revise_config: Optional[str], 223 | expected: bool, 224 | ) -> None: 225 | bash( 226 | """ 227 | echo "hello, world" > file1 228 | git add file1 229 | git commit -m "commit one" 230 | 231 | echo "second file" > file2 232 | git add file2 233 | git commit -m "commit two" 234 | 235 | echo "new line!" >> file1 236 | git add file1 237 | git commit -m "commit three" 238 | 239 | echo "extra line" >> file2 240 | git add file2 241 | git commit --fixup=HEAD~ 242 | """ 243 | ) 244 | 245 | if rebase_config is not None: 246 | bash(f"git config rebase.autoSquash '{rebase_config}'") 247 | if revise_config is not None: 248 | bash(f"git config revise.autoSquash '{revise_config}'") 249 | 250 | head = repo.get_commit("HEAD") 251 | headu = head.parent() 252 | headuu = headu.parent() 253 | 254 | disabled = f"""\ 255 | pick {headuu.oid.short()} commit two 256 | pick {headu.oid.short()} commit three 257 | pick {head.oid.short()} fixup! commit two 258 | 259 | """ 260 | enabled = f"""\ 261 | pick {headuu.oid.short()} commit two 262 | fixup {head.oid.short()} fixup! commit two 263 | pick {headu.oid.short()} commit three 264 | 265 | """ 266 | 267 | def subtest(args: Sequence[str], expected_todos: str) -> None: 268 | with editor_main((*args, "-i", "HEAD~3")) as ed: 269 | with ed.next_file() as f: 270 | assert f.startswith_dedent(expected_todos) 271 | f.replace_dedent(disabled) # don't mutate state 272 | 273 | assert repo.get_commit("HEAD") == head 274 | 275 | subtest([], enabled if expected else disabled) 276 | subtest(["--autosquash"], enabled) 277 | subtest(["--no-autosquash"], disabled) 278 | 279 | 280 | def test_interactive_reword(repo: Repository) -> None: 281 | bash( 282 | """ 283 | echo "hello, world" > file1 284 | git add file1 285 | git commit -m "commit one" -m "extended1" 286 | 287 | echo "second file" > file2 288 | git add file2 289 | git commit -m "commit two" -m "extended2" 290 | 291 | echo "new line!" >> file1 292 | git add file1 293 | git commit -m "commit three" -m "extended3" 294 | """ 295 | ) 296 | 297 | prev = repo.get_commit("HEAD") 298 | prev_u = prev.parent() 299 | prev_uu = prev_u.parent() 300 | 301 | with editor_main(["-ie", "HEAD~~"]) as ed: 302 | with ed.next_file() as f: 303 | assert f.startswith_dedent( 304 | f"""\ 305 | ++ pick {prev.parent().oid.short()} 306 | commit two 307 | 308 | extended2 309 | 310 | ++ pick {prev.oid.short()} 311 | commit three 312 | 313 | extended3 314 | """ 315 | ) 316 | f.replace_dedent( 317 | f"""\ 318 | ++ pick {prev.oid.short()} 319 | updated commit three 320 | 321 | extended3 updated 322 | 323 | ++ pick {prev.parent().oid.short()} 324 | updated commit two 325 | 326 | extended2 updated 327 | """ 328 | ) 329 | 330 | curr = repo.get_commit("HEAD") 331 | curr_u = curr.parent() 332 | curr_uu = curr_u.parent() 333 | 334 | assert curr != prev 335 | assert curr.tree() == prev.tree() 336 | assert curr_u.message == b"updated commit three\n\nextended3 updated\n" 337 | assert curr.message == b"updated commit two\n\nextended2 updated\n" 338 | assert curr_uu == prev_uu 339 | 340 | assert b"file2" in prev_u.tree().entries 341 | assert b"file2" not in curr_u.tree().entries 342 | 343 | assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] 344 | assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] 345 | assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] 346 | -------------------------------------------------------------------------------- /tests/test_rerere.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from gitrevise.merge import normalize_conflicted_file 4 | from gitrevise.odb import Repository 5 | 6 | from .conftest import Editor, bash, changeline, editor_main, main 7 | 8 | 9 | def history_with_two_conflicting_commits(auto_update: bool = False) -> None: 10 | bash( 11 | f""" 12 | git config rerere.enabled true 13 | git config rerere.autoUpdate {"true" if auto_update else "false"} 14 | echo > file; git add file; git commit -m 'initial commit' 15 | echo one > file; git commit -am 'commit one' 16 | echo two > file; git commit -am 'commit two' 17 | """ 18 | ) 19 | 20 | 21 | def test_reuse_recorded_resolution(repo: Repository) -> None: 22 | history_with_two_conflicting_commits(auto_update=True) 23 | 24 | with editor_main(("-i", "HEAD~~"), input=b"y\ny\ny\ny\n") as ed: 25 | flip_last_two_commits(repo, ed) 26 | with ed.next_file() as f: 27 | f.replace_dedent("resolved two\n") 28 | with ed.next_file() as f: 29 | f.replace_dedent("resolved one\n") 30 | 31 | tree_after_resolving_conflicts = repo.get_commit("HEAD").tree() 32 | bash("git reset --hard HEAD@{1}") 33 | 34 | # Now we can change the order of the two commits and reuse the recorded conflict resolution. 35 | with editor_main(("-i", "HEAD~~")) as ed: 36 | flip_last_two_commits(repo, ed) 37 | 38 | assert tree_after_resolving_conflicts == repo.get_commit("HEAD").tree() 39 | leftover_index = hunks(repo.git("diff", "-U0", "HEAD")) 40 | assert leftover_index == dedent( 41 | """\ 42 | @@ -1 +1 @@ 43 | -resolved one 44 | +two""" 45 | ) 46 | 47 | # When we fail to read conflict data from the cache, we fall back to 48 | # letting the user resolve the conflict. 49 | bash("git reset --hard HEAD@{1}") 50 | bash("rm .git/rr-cache/*/preimage") 51 | with editor_main(("-i", "HEAD~~"), input=b"y\ny\ny\ny\n") as ed: 52 | flip_last_two_commits(repo, ed) 53 | with ed.next_file() as f: 54 | f.replace_dedent("resolved two\n") 55 | with ed.next_file() as f: 56 | f.replace_dedent("resolved one\n") 57 | 58 | 59 | def test_rerere_no_autoupdate(repo: Repository) -> None: 60 | history_with_two_conflicting_commits(auto_update=False) 61 | 62 | with editor_main(("-i", "HEAD~~"), input=b"y\ny\ny\ny\n") as ed: 63 | flip_last_two_commits(repo, ed) 64 | with ed.next_file() as f: 65 | f.replace_dedent("resolved two\n") 66 | with ed.next_file() as f: 67 | f.replace_dedent("resolved one\n") 68 | 69 | tree_after_resolving_conflicts = repo.get_commit("HEAD").tree() 70 | bash("git reset --hard HEAD@{1}") 71 | 72 | # Use the recorded resolution by confirming both times. 73 | with editor_main(("-i", "HEAD~~"), input=b"y\ny\n") as ed: 74 | flip_last_two_commits(repo, ed) 75 | assert tree_after_resolving_conflicts == repo.get_commit("HEAD").tree() 76 | leftover_index = hunks(repo.git("diff", "-U0", "HEAD")) 77 | assert leftover_index == dedent( 78 | """\ 79 | @@ -1 +1 @@ 80 | -resolved one 81 | +two""" 82 | ) 83 | bash("git reset --hard HEAD@{1}") 84 | 85 | # Do not use the recorded resolution for the second commit. 86 | with editor_main(("-i", "HEAD~~"), input=b"y\nn\ny\ny\n") as ed: 87 | flip_last_two_commits(repo, ed) 88 | with ed.next_file() as f: 89 | f.replace_dedent("resolved differently\n") 90 | leftover_index = hunks(repo.git("diff", "-U0", "HEAD")) 91 | assert leftover_index == dedent( 92 | """\ 93 | @@ -1 +1 @@ 94 | -resolved differently 95 | +two""" 96 | ) 97 | 98 | 99 | def test_rerere_merge(repo: Repository) -> None: 100 | (repo.workdir / "file").write_bytes(10 * b"x\n") 101 | bash( 102 | """ 103 | git config rerere.enabled true 104 | git config rerere.autoUpdate true 105 | git add file; git commit -m 'initial commit' 106 | """ 107 | ) 108 | changeline("file", 0, b"original1\n") 109 | bash("git commit -am 'commit 1'") 110 | changeline("file", 0, b"original2\n") 111 | bash("git commit -am 'commit 2'") 112 | 113 | # Record a resolution for changing the order of two commits. 114 | with editor_main(("-i", "HEAD~~"), input=b"y\ny\ny\ny\n") as ed: 115 | flip_last_two_commits(repo, ed) 116 | with ed.next_file() as f: 117 | f.replace_dedent(b"resolved1\n" + 9 * b"x\n") 118 | with ed.next_file() as f: 119 | f.replace_dedent(b"resolved2\n" + 9 * b"x\n") 120 | # Go back to the old history so we can try replaying the resolution. 121 | bash("git reset --hard HEAD@{1}") 122 | 123 | # Introduce an unrelated change that will not conflict to check that we can 124 | # merge the file contents, and not just use the recorded postimage as is. 125 | changeline("file", 9, b"unrelated change, present in all commits\n") 126 | bash("git add file") 127 | main(["HEAD~2"]) 128 | 129 | with editor_main(("-i", "HEAD~~")) as ed: 130 | flip_last_two_commits(repo, ed) 131 | 132 | assert hunks(repo.git("show", "-U0", "HEAD~")) == dedent( 133 | """\ 134 | @@ -1 +1 @@ 135 | -x 136 | +resolved1""" 137 | ) 138 | assert hunks(repo.git("show", "-U0", "HEAD")) == dedent( 139 | """\ 140 | @@ -1 +1 @@ 141 | -resolved1 142 | +resolved2""" 143 | ) 144 | leftover_index = hunks(repo.git("diff", "-U0", "HEAD")) 145 | assert leftover_index == dedent( 146 | """\ 147 | @@ -1 +1 @@ 148 | -resolved2 149 | +original2""" 150 | ) 151 | 152 | 153 | def test_replay_resolution_recorded_by_git(repo: Repository) -> None: 154 | history_with_two_conflicting_commits(auto_update=True) 155 | # Switch the order of the last two commits, recording the conflict 156 | # resolution with Git itself. 157 | bash( 158 | r""" 159 | one=$(git rev-parse HEAD~) 160 | two=$(git rev-parse HEAD) 161 | git reset --hard HEAD~~ 162 | git cherry-pick "$two" 2>&1 | grep 'could not apply' 163 | echo resolved two > file; git add file; GIT_EDITOR=: git cherry-pick --continue 164 | git cherry-pick "$one" 2>&1 | grep 'could not apply' 165 | echo resolved one > file; git add file; GIT_EDITOR=: git cherry-pick --continue --no-edit 166 | git reset --hard "$two" 167 | """ 168 | ) 169 | 170 | # Now let's try to do the same thing with git-revise, reusing the recorded resolution. 171 | with editor_main(("-i", "HEAD~~")) as ed: 172 | flip_last_two_commits(repo, ed) 173 | 174 | assert repo.git("log", "-p", trim_newline=False) == dedent( 175 | """\ 176 | commit dc50430ecbd2d0697ee9266ba6057e0e0b511d7f 177 | Author: Bash Author 178 | Date: Thu Jul 13 21:40:00 2017 -0500 179 | 180 | commit one 181 | 182 | diff --git a/file b/file 183 | index 474b904..936bcfd 100644 184 | --- a/file 185 | +++ b/file 186 | @@ -1 +1 @@ 187 | -resolved two 188 | +resolved one 189 | 190 | commit e51ab202e87f0557df78e5273dcedf51f408a468 191 | Author: Bash Author 192 | Date: Thu Jul 13 21:40:00 2017 -0500 193 | 194 | commit two 195 | 196 | diff --git a/file b/file 197 | index 8b13789..474b904 100644 198 | --- a/file 199 | +++ b/file 200 | @@ -1 +1 @@ 201 | - 202 | +resolved two 203 | 204 | commit d72132e74176624d6c3e5b6b4f5ef774ff23a1b3 205 | Author: Bash Author 206 | Date: Thu Jul 13 21:40:00 2017 -0500 207 | 208 | initial commit 209 | 210 | diff --git a/file b/file 211 | new file mode 100644 212 | index 0000000..8b13789 213 | --- /dev/null 214 | +++ b/file 215 | @@ -0,0 +1 @@ 216 | + 217 | """ 218 | ) 219 | 220 | 221 | def test_normalize_conflicted_file() -> None: 222 | # Normalize conflict markers and labels. 223 | assert normalize_conflicted_file( 224 | dedent( 225 | """\ 226 | <<<<<<< HEAD 227 | a 228 | ======= 229 | b 230 | >>>>>>> original thingamabob 231 | 232 | unrelated line 233 | 234 | <<<<<<<<<< HEAD 235 | c 236 | ========== 237 | d 238 | >>>>>>>>>> longer conflict marker, to be ignored 239 | """ 240 | ) 241 | ) == ( 242 | dedent( 243 | """\ 244 | <<<<<<< 245 | a 246 | ======= 247 | b 248 | >>>>>>> 249 | 250 | unrelated line 251 | 252 | <<<<<<<<<< HEAD 253 | c 254 | ========== 255 | d 256 | >>>>>>>>>> longer conflict marker, to be ignored 257 | """ 258 | ), 259 | "0630df854874fc5ffb92a197732cce0d8928e898", 260 | ) 261 | 262 | # Discard original-text-marker from merge.conflictStyle diff3. 263 | assert normalize_conflicted_file( 264 | dedent( 265 | """\ 266 | <<<<<<< theirs 267 | a 268 | ||||||| common origin 269 | b 270 | ======= 271 | c 272 | >>>>>>> ours 273 | """ 274 | ) 275 | )[0] == dedent( 276 | """\ 277 | <<<<<<< 278 | a 279 | ======= 280 | c 281 | >>>>>>> 282 | """ 283 | ) 284 | 285 | # The two sides of the conflict are ordered. 286 | assert normalize_conflicted_file( 287 | dedent( 288 | """\ 289 | <<<<<<< this way round 290 | b 291 | ======= 292 | a 293 | >>>>>>> (unsorted) 294 | """ 295 | ) 296 | )[0] == dedent( 297 | """\ 298 | <<<<<<< 299 | a 300 | ======= 301 | b 302 | >>>>>>> 303 | """ 304 | ) 305 | 306 | # Nested conflict markers. 307 | assert normalize_conflicted_file( 308 | dedent( 309 | """\ 310 | <<<<<<< ours (outer) 311 | outer left 312 | <<<<<<< ours (inner) 313 | inner left 314 | ||||||| 315 | inner diff3 original section 316 | ======= 317 | inner right 318 | >>>>>>> theirs (inner) 319 | ======= 320 | outer right 321 | >>>>>>> theirs (outer) 322 | """ 323 | ) 324 | )[0] == dedent( 325 | """\ 326 | <<<<<<< 327 | outer left 328 | <<<<<<< 329 | inner left 330 | ======= 331 | inner right 332 | >>>>>>> 333 | ======= 334 | outer right 335 | >>>>>>> 336 | """ 337 | ) 338 | 339 | 340 | def flip_last_two_commits(repo: Repository, ed: Editor) -> None: 341 | head = repo.get_commit("HEAD") 342 | with ed.next_file() as f: 343 | lines = f.indata.splitlines() 344 | assert lines[0].startswith(b"pick " + head.parent().oid.short().encode()) 345 | assert lines[1].startswith(b"pick " + head.oid.short().encode()) 346 | assert not lines[2], "expect todo list with exactly two items" 347 | 348 | f.replace_dedent( 349 | f"""\ 350 | pick {head.oid.short()} 351 | pick {head.parent().oid.short()} 352 | """ 353 | ) 354 | 355 | 356 | def dedent(text: str) -> bytes: 357 | return textwrap.dedent(text).encode() 358 | 359 | 360 | def hunks(diff: bytes) -> bytes: 361 | return diff[diff.index(b"@@") :] 362 | -------------------------------------------------------------------------------- /tests/test_fixup.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Generator, Optional, Sequence 3 | 4 | import pytest 5 | 6 | from gitrevise.odb import Repository 7 | from gitrevise.todo import StepKind, autosquash_todos, build_todos 8 | from gitrevise.utils import commit_range 9 | 10 | from .conftest import Editor, bash, editor_main, main 11 | 12 | 13 | @pytest.fixture(name="basic_repo") 14 | def fixture_basic_repo(repo: Repository) -> Repository: 15 | bash( 16 | """ 17 | cat <file1 18 | Hello, World! 19 | How are things? 20 | EOF 21 | git add file1 22 | git commit -m "commit1" 23 | 24 | cat <file1 25 | Hello, World! 26 | Oops, gotta add a new line! 27 | How are things? 28 | EOF 29 | git add file1 30 | git commit -m "commit2" 31 | """ 32 | ) 33 | return repo 34 | 35 | 36 | def fixup_helper( 37 | repo: Repository, 38 | flags: Sequence[str], 39 | target: str, 40 | message: Optional[str] = None, 41 | ) -> None: 42 | with fixup_helper_editor(repo=repo, flags=flags, target=target, message=message): 43 | pass 44 | 45 | 46 | @contextmanager 47 | def fixup_helper_editor( 48 | repo: Repository, 49 | flags: Sequence[str], 50 | target: str, 51 | message: Optional[str] = None, 52 | ) -> Generator[Editor, None, None]: 53 | old = repo.get_commit(target) 54 | assert old.persisted 55 | 56 | bash( 57 | """ 58 | echo "extra line" >> file1 59 | git add file1 60 | """ 61 | ) 62 | 63 | with editor_main((*flags, target)) as ed: 64 | yield ed 65 | 66 | new = repo.get_commit(target) 67 | assert old != new, "commit was modified" 68 | assert old.parents() == new.parents(), "parents are unchanged" 69 | 70 | assert old.tree() != new.tree(), "tree is changed" 71 | 72 | if message is None: 73 | assert new.message == old.message, "message should not be changed" 74 | else: 75 | assert new.message == message.encode(), "message set correctly" 76 | 77 | assert new.persisted, "commit persisted to disk" 78 | assert new.author == old.author, "author is unchanged" 79 | assert new.committer == repo.default_committer, "committer is updated" 80 | 81 | 82 | def test_fixup_head(basic_repo: Repository) -> None: 83 | fixup_helper(basic_repo, [], "HEAD") 84 | 85 | 86 | def test_fixup_nonhead(basic_repo: Repository) -> None: 87 | fixup_helper(basic_repo, [], "HEAD~") 88 | 89 | 90 | def test_fixup_head_msg(basic_repo: Repository) -> None: 91 | fixup_helper( 92 | basic_repo, 93 | ["-m", "fixup_head test", "-m", "another line"], 94 | "HEAD", 95 | "fixup_head test\n\nanother line\n", 96 | ) 97 | 98 | 99 | def test_fixup_nonhead_msg(basic_repo: Repository) -> None: 100 | fixup_helper( 101 | basic_repo, 102 | ["-m", "fixup_nonhead test", "-m", "another line"], 103 | "HEAD~", 104 | "fixup_nonhead test\n\nanother line\n", 105 | ) 106 | 107 | 108 | def test_fixup_head_editor(basic_repo: Repository) -> None: 109 | old = basic_repo.get_commit("HEAD") 110 | newmsg = "fixup_head_editor test\n\nanother line\n" 111 | 112 | with fixup_helper_editor(basic_repo, ["-e"], "HEAD", newmsg) as ed: 113 | with ed.next_file() as f: 114 | assert f.startswith(old.message) 115 | f.replace_dedent(newmsg) 116 | 117 | 118 | def test_fixup_nonhead_editor(basic_repo: Repository) -> None: 119 | old = basic_repo.get_commit("HEAD~") 120 | newmsg = "fixup_nonhead_editor test\n\nanother line\n" 121 | 122 | with fixup_helper_editor(basic_repo, ["-e"], "HEAD~", newmsg) as ed: 123 | with ed.next_file() as f: 124 | assert f.startswith(old.message) 125 | f.replace_dedent(newmsg) 126 | 127 | 128 | def test_fixup_nonhead_conflict(basic_repo: Repository) -> None: 129 | bash('echo "conflict" > file1') 130 | bash("git add file1") 131 | 132 | old = basic_repo.get_commit("HEAD~") 133 | assert old.persisted 134 | 135 | with editor_main(["HEAD~"], input=b"y\ny\ny\ny\n") as ed: 136 | with ed.next_file() as f: 137 | assert f.equals_dedent( 138 | """\ 139 | <<<<<<< file1 (new parent): commit1 140 | Hello, World! 141 | How are things? 142 | ======= 143 | conflict 144 | >>>>>>> file1 (current): 145 | """ 146 | ) 147 | f.replace_dedent("conflict1\n") 148 | 149 | with ed.next_file() as f: 150 | assert f.equals_dedent( 151 | """\ 152 | <<<<<<< file1 (new parent): commit1 153 | conflict1 154 | ======= 155 | Hello, World! 156 | Oops, gotta add a new line! 157 | How are things? 158 | >>>>>>> file1 (current): commit2 159 | """ 160 | ) 161 | f.replace_dedent("conflict2\n") 162 | 163 | new = basic_repo.get_commit("HEAD~") 164 | assert new.persisted 165 | assert new != old 166 | 167 | 168 | def test_autosquash_nonhead(repo: Repository) -> None: 169 | bash( 170 | """ 171 | echo "hello, world" > file1 172 | git add file1 173 | git commit -m "commit one" 174 | 175 | echo "second file" > file2 176 | git add file2 177 | git commit -m "commit two" 178 | 179 | echo "new line!" >> file1 180 | git add file1 181 | git commit -m "commit three" 182 | 183 | echo "extra line" >> file2 184 | git add file2 185 | git commit --fixup=HEAD~ 186 | """ 187 | ) 188 | 189 | old = repo.get_commit("HEAD~~") 190 | assert old.persisted 191 | 192 | main(["--autosquash", str(old.parent().oid)]) 193 | 194 | new = repo.get_commit("HEAD~") 195 | assert old != new, "commit was modified" 196 | assert old.parents() == new.parents(), "parents are unchanged" 197 | 198 | assert old.tree() != new.tree(), "tree is changed" 199 | 200 | assert new.message == old.message, "message should not be changed" 201 | 202 | assert new.persisted, "commit persisted to disk" 203 | assert new.author == old.author, "author is unchanged" 204 | assert new.committer == repo.default_committer, "committer is updated" 205 | 206 | file1 = new.tree().entries[b"file1"].blob().body 207 | assert file1 == b"hello, world\n" 208 | file2 = new.tree().entries[b"file2"].blob().body 209 | assert file2 == b"second file\nextra line\n" 210 | 211 | 212 | def test_fixup_of_fixup(repo: Repository) -> None: 213 | bash( 214 | """ 215 | echo "hello, world" > file1 216 | git add file1 217 | git commit -m "commit one" 218 | 219 | echo "second file" > file2 220 | git add file2 221 | git commit -m "commit two" 222 | 223 | echo "new line!" >> file1 224 | git add file1 225 | git commit -m "commit three" 226 | 227 | echo "extra line" >> file2 228 | git add file2 229 | git commit --fixup=HEAD~ 230 | 231 | echo "even more" >> file2 232 | git add file2 233 | git commit --fixup=HEAD 234 | """ 235 | ) 236 | 237 | old = repo.get_commit("HEAD~~~") 238 | assert old.persisted 239 | 240 | main(["--autosquash", str(old.parent().oid)]) 241 | 242 | new = repo.get_commit("HEAD~") 243 | assert old != new, "commit was modified" 244 | assert old.parents() == new.parents(), "parents are unchanged" 245 | 246 | assert old.tree() != new.tree(), "tree is changed" 247 | 248 | assert new.message == old.message, "message should not be changed" 249 | 250 | assert new.persisted, "commit persisted to disk" 251 | assert new.author == old.author, "author is unchanged" 252 | assert new.committer == repo.default_committer, "committer is updated" 253 | 254 | file1 = new.tree().entries[b"file1"].blob().body 255 | assert file1 == b"hello, world\n" 256 | file2 = new.tree().entries[b"file2"].blob().body 257 | assert file2 == b"second file\nextra line\neven more\n" 258 | 259 | 260 | def test_fixup_by_id(repo: Repository) -> None: 261 | bash( 262 | """ 263 | echo "hello, world" > file1 264 | git add file1 265 | git commit -m "commit one" 266 | 267 | echo "second file" > file2 268 | git add file2 269 | git commit -m "commit two" 270 | 271 | echo "new line!" >> file1 272 | git add file1 273 | git commit -m "commit three" 274 | 275 | echo "extra line" >> file2 276 | git add file2 277 | git commit -m "fixup! $(git rev-parse HEAD~)" 278 | """ 279 | ) 280 | 281 | old = repo.get_commit("HEAD~~") 282 | assert old.persisted 283 | 284 | main(["--autosquash", str(old.parent().oid)]) 285 | 286 | new = repo.get_commit("HEAD~") 287 | assert old != new, "commit was modified" 288 | assert old.parents() == new.parents(), "parents are unchanged" 289 | 290 | assert old.tree() != new.tree(), "tree is changed" 291 | 292 | assert new.message == old.message, "message should not be changed" 293 | 294 | assert new.persisted, "commit persisted to disk" 295 | assert new.author == old.author, "author is unchanged" 296 | assert new.committer == repo.default_committer, "committer is updated" 297 | 298 | file1 = new.tree().entries[b"file1"].blob().body 299 | assert file1 == b"hello, world\n" 300 | file2 = new.tree().entries[b"file2"].blob().body 301 | assert file2 == b"second file\nextra line\n" 302 | 303 | 304 | def test_fixup_order(repo: Repository) -> None: 305 | bash( 306 | """ 307 | git commit --allow-empty -m 'old' 308 | git commit --allow-empty -m 'target commit' 309 | git commit --allow-empty -m 'first fixup' --fixup=HEAD 310 | git commit --allow-empty -m 'second fixup' --fixup=HEAD~ 311 | """ 312 | ) 313 | 314 | old = repo.get_commit("HEAD~3") 315 | assert old.persisted 316 | tip = repo.get_commit("HEAD") 317 | assert tip.persisted 318 | 319 | todos = build_todos(commit_range(old, tip), index=None) 320 | [target, first, second] = autosquash_todos(todos) 321 | 322 | assert b"target commit" in target.commit.message 323 | assert b"first fixup" in first.commit.message 324 | assert b"second fixup" in second.commit.message 325 | 326 | 327 | def test_fixup_order_transitive(repo: Repository) -> None: 328 | bash( 329 | """ 330 | git commit --allow-empty -m 'old' 331 | git commit --allow-empty -m 'target commit' 332 | git commit --allow-empty -m '1.0' --fixup=HEAD 333 | git commit --allow-empty -m '1.1' --fixup=HEAD 334 | git commit --allow-empty -m '2.0' --fixup=HEAD~2 335 | """ 336 | ) 337 | 338 | old = repo.get_commit("HEAD~4") 339 | assert old.persisted 340 | tip = repo.get_commit("HEAD") 341 | assert tip.persisted 342 | 343 | todos = build_todos(commit_range(old, tip), index=None) 344 | [target, a, b, c] = autosquash_todos(todos) # pylint: disable=invalid-name 345 | 346 | assert b"target commit" in target.commit.message 347 | assert b"1.0" in a.commit.message 348 | assert b"1.1" in b.commit.message 349 | assert b"2.0" in c.commit.message 350 | 351 | 352 | def test_fixup_order_cycle(repo: Repository) -> None: 353 | bash( 354 | """ 355 | git commit --allow-empty -m 'old' 356 | git commit --allow-empty -m 'fixup! fixup!' # Cannot fixup self. 357 | git commit --allow-empty -m 'fixup! future commit' # Cannot fixup future commit. 358 | git commit --allow-empty -m 'future commit' 359 | """ 360 | ) 361 | 362 | old = repo.get_commit("HEAD~3") 363 | assert old.persisted 364 | tip = repo.get_commit("HEAD") 365 | assert tip.persisted 366 | 367 | todos = build_todos(commit_range(old, tip), index=None) 368 | 369 | new_todos = autosquash_todos(todos) 370 | assert len(new_todos) == 3 371 | assert all(step.kind == StepKind.PICK for step in new_todos) 372 | 373 | 374 | def test_autosquash_multiline_summary(repo: Repository) -> None: 375 | bash( 376 | """ 377 | git commit --allow-empty -m 'initial commit' 378 | git commit --allow-empty -m 'multi 379 | line 380 | summary 381 | 382 | body goes here 383 | ' 384 | 385 | echo >file 386 | git add file 387 | git commit --fixup=HEAD 388 | """ 389 | ) 390 | 391 | old = repo.get_commit("HEAD~") 392 | assert old.persisted 393 | 394 | main(["--autosquash"]) 395 | 396 | new = repo.get_commit("HEAD") 397 | assert old != new, "commit was modified" 398 | assert old.parents() == new.parents(), "parents are unchanged" 399 | -------------------------------------------------------------------------------- /gitrevise/merge.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains a basic implementation of an efficient, in-memory 3-way 3 | git tree merge. This is used rather than traditional git mechanisms to avoid 4 | needing to use the index file format, which can be slow to initialize for 5 | large repositories. 6 | 7 | The INDEX file for my local mozilla-central checkout, for reference, is 35MB. 8 | While this isn't huge, it takes a perceptable amount of time to read the tree 9 | files and generate. This algorithm, on the other hand, avoids looking at 10 | unmodified trees and blobs when possible. 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | import hashlib 16 | import os 17 | import sys 18 | from pathlib import Path 19 | from subprocess import CalledProcessError 20 | from typing import Iterator, Optional, Tuple, TypeVar 21 | 22 | from .odb import Blob, Commit, Entry, Mode, Repository, Tree 23 | from .utils import edit_file 24 | 25 | T = TypeVar("T") # pylint: disable=invalid-name 26 | 27 | 28 | class MergeConflict(Exception): 29 | pass 30 | 31 | 32 | def rebase(commit: Commit, new_parent: Optional[Commit]) -> Commit: 33 | repo = commit.repo 34 | 35 | orig_parent = commit.parent() if not commit.is_root else None 36 | 37 | if orig_parent == new_parent: 38 | return commit # No need to do anything 39 | 40 | def get_summary(cmt: Optional[Commit]) -> str: 41 | return cmt.summary() if cmt is not None else "" 42 | 43 | def get_tree(cmt: Optional[Commit]) -> Tree: 44 | return cmt.tree() if cmt is not None else Tree(repo, b"") 45 | 46 | tree = merge_trees( 47 | Path(), 48 | (get_summary(new_parent), get_summary(orig_parent), get_summary(commit)), 49 | get_tree(new_parent), 50 | get_tree(orig_parent), 51 | get_tree(commit), 52 | ) 53 | 54 | new_parents = [new_parent] if new_parent is not None else [] 55 | 56 | # NOTE: This omits commit.committer to pull it from the environment. This 57 | # means that created commits may vary between invocations, but due to 58 | # caching, should be consistent within a single process. 59 | return commit.update(tree=tree, parents=new_parents) 60 | 61 | 62 | def conflict_prompt( 63 | path: Path, 64 | descr: str, 65 | labels: Tuple[str, str, str], 66 | current: T, 67 | current_descr: str, 68 | other: T, 69 | other_descr: str, 70 | ) -> T: 71 | print(f"{descr} conflict for '{path}'") 72 | print(f" (1) {labels[0]}: {current_descr}") 73 | print(f" (2) {labels[2]}: {other_descr}") 74 | char = input("Resolution or (A)bort? ") 75 | if char == "1": 76 | return current 77 | if char == "2": 78 | return other 79 | raise MergeConflict("aborted") 80 | 81 | 82 | def merge_trees( 83 | path: Path, labels: Tuple[str, str, str], current: Tree, base: Tree, other: Tree 84 | ) -> Tree: 85 | # Merge every named entry which is mentioned in any tree. 86 | names = set(current.entries.keys()).union(base.entries.keys(), other.entries.keys()) 87 | entries = {} 88 | for name in names: 89 | merged = merge_entries( 90 | path / name.decode(errors="replace"), 91 | labels, 92 | current.entries.get(name), 93 | base.entries.get(name), 94 | other.entries.get(name), 95 | ) 96 | if merged is not None: 97 | entries[name] = merged 98 | return current.repo.new_tree(entries) 99 | 100 | 101 | def merge_entries( 102 | path: Path, 103 | labels: Tuple[str, str, str], 104 | current: Optional[Entry], 105 | base: Optional[Entry], 106 | other: Optional[Entry], 107 | ) -> Optional[Entry]: 108 | if base == current: 109 | return other # no change from base -> current 110 | if base == other: 111 | return current # no change from base -> other 112 | if current == other: 113 | return current # base -> current & base -> other are identical 114 | 115 | # If one of the branches deleted the entry, and the other modified it, 116 | # report a merge conflict. 117 | if current is None: 118 | return conflict_prompt( 119 | path, "Deletion", labels, current, "deleted", other, "modified" 120 | ) 121 | if other is None: 122 | return conflict_prompt( 123 | path, "Deletion", labels, current, "modified", other, "deleted" 124 | ) 125 | 126 | # Determine which mode we're working with here. 127 | if current.mode == other.mode: 128 | mode = current.mode # current & other agree 129 | elif current.mode.is_file() and other.mode.is_file(): 130 | # File types support both Mode.EXEC and Mode.REGULAR, try to pick one. 131 | if base and base.mode == current.mode: 132 | mode = other.mode 133 | elif base and base.mode == other.mode: 134 | mode = current.mode 135 | else: 136 | mode = conflict_prompt( 137 | path, 138 | "File mode", 139 | labels, 140 | current.mode, 141 | str(current.mode), 142 | other.mode, 143 | str(other.mode), 144 | ) 145 | else: 146 | return conflict_prompt( 147 | path, 148 | "Entry type", 149 | labels, 150 | current, 151 | str(current.mode), 152 | other, 153 | str(other.mode), 154 | ) 155 | 156 | # Time to merge the actual entries! 157 | if mode.is_file(): 158 | baseblob = None 159 | if base and base.mode.is_file(): 160 | baseblob = base.blob() 161 | return Entry( 162 | current.repo, 163 | mode, 164 | merge_blobs(path, labels, current.blob(), baseblob, other.blob()).oid, 165 | ) 166 | if mode == Mode.DIR: 167 | basetree = current.repo.new_tree({}) 168 | if base and base.mode == Mode.DIR: 169 | basetree = base.tree() 170 | return Entry( 171 | current.repo, 172 | mode, 173 | merge_trees(path, labels, current.tree(), basetree, other.tree()).oid, 174 | ) 175 | if mode == Mode.SYMLINK: 176 | return conflict_prompt( 177 | path, 178 | "Symlink", 179 | labels, 180 | current, 181 | current.symlink().decode(), 182 | other, 183 | other.symlink().decode(), 184 | ) 185 | if mode == Mode.GITLINK: 186 | return conflict_prompt( 187 | path, "Submodule", labels, current, str(current.oid), other, str(other.oid) 188 | ) 189 | 190 | raise ValueError("unknown mode") 191 | 192 | 193 | def merge_blobs( 194 | path: Path, 195 | labels: Tuple[str, str, str], 196 | current: Blob, 197 | base: Optional[Blob], 198 | other: Blob, 199 | ) -> Blob: 200 | repo = current.repo 201 | 202 | tmpdir = repo.get_tempdir() 203 | 204 | annotated_labels = ( 205 | f"{path} (new parent): {labels[0]}", 206 | f"{path} (old parent): {labels[1]}", 207 | f"{path} (current): {labels[2]}", 208 | ) 209 | (is_clean_merge, merged) = merge_files( 210 | repo, 211 | annotated_labels, 212 | current.body, 213 | base.body if base else b"", 214 | other.body, 215 | tmpdir, 216 | ) 217 | 218 | if is_clean_merge: 219 | # No conflicts. 220 | return Blob(repo, merged) 221 | 222 | # At this point, we know that there are merge conflicts to resolve. 223 | # Prompt to try and trigger manual resolution. 224 | print(f"Conflict applying '{labels[2]}'") 225 | print(f" Path: '{path}'") 226 | 227 | preimage = merged 228 | (normalized_preimage, conflict_id, merged_blob) = replay_recorded_resolution( 229 | repo, tmpdir, preimage 230 | ) 231 | if merged_blob is not None: 232 | return merged_blob 233 | 234 | if input(" Edit conflicted file? (Y/n) ").lower() == "n": 235 | raise MergeConflict("user aborted") 236 | 237 | # Open the editor on the conflicted file. We ensure the relative path 238 | # matches the path of the original file for a better editor experience. 239 | conflicts = tmpdir / "conflict" / path 240 | conflicts.parent.mkdir(parents=True, exist_ok=True) 241 | conflicts.write_bytes(preimage) 242 | merged = edit_file(repo, conflicts) 243 | 244 | # Print warnings if the merge looks like it may have failed. 245 | if merged == preimage: 246 | print("(note) conflicted file is unchanged") 247 | 248 | if b"<<<<<<<" in merged or b"=======" in merged or b">>>>>>>" in merged: 249 | print("(note) conflict markers found in the merged file") 250 | 251 | # Was the merge successful? 252 | if input(" Merge successful? (y/N) ").lower() != "y": 253 | raise MergeConflict("user aborted") 254 | 255 | record_resolution(repo, conflict_id, normalized_preimage, merged) 256 | 257 | return Blob(current.repo, merged) 258 | 259 | 260 | def merge_files( 261 | repo: Repository, 262 | labels: Tuple[str, str, str], 263 | current: bytes, 264 | base: bytes, 265 | other: bytes, 266 | tmpdir: Path, 267 | ) -> Tuple[bool, bytes]: 268 | (tmpdir / "current").write_bytes(current) 269 | (tmpdir / "base").write_bytes(base) 270 | (tmpdir / "other").write_bytes(other) 271 | 272 | # Try running git merge-file to automatically resolve conflicts. 273 | try: 274 | merged = repo.git( 275 | "merge-file", 276 | "-q", 277 | "-p", 278 | f"-L{labels[0]}", 279 | f"-L{labels[1]}", 280 | f"-L{labels[2]}", 281 | str(tmpdir / "current"), 282 | str(tmpdir / "base"), 283 | str(tmpdir / "other"), 284 | trim_newline=False, 285 | ) 286 | 287 | return (True, merged) # Successful merge 288 | except CalledProcessError as err: 289 | # The return code is the # of conflicts if there are conflicts, and 290 | # negative if there is an error. 291 | if err.returncode < 0: 292 | raise 293 | 294 | return (False, err.output) # Conflicted merge 295 | 296 | 297 | def replay_recorded_resolution( 298 | repo: Repository, tmpdir: Path, preimage: bytes 299 | ) -> Tuple[bytes, Optional[str], Optional[Blob]]: 300 | rr_cache = repo.git_path("rr-cache") 301 | if not repo.bool_config( 302 | "revise.rerere", 303 | default=repo.bool_config("rerere.enabled", default=rr_cache.is_dir()), 304 | ): 305 | return (b"", None, None) 306 | 307 | (normalized_preimage, conflict_id) = normalize_conflicted_file(preimage) 308 | conflict_dir = rr_cache / conflict_id 309 | if not conflict_dir.is_dir(): 310 | return (normalized_preimage, conflict_id, None) 311 | if not repo.bool_config("rerere.autoUpdate", default=False): 312 | if input(" Apply recorded resolution? (y/N) ").lower() != "y": 313 | return (b"", None, None) 314 | 315 | postimage_path = conflict_dir / "postimage" 316 | preimage_path = conflict_dir / "preimage" 317 | try: 318 | recorded_postimage = postimage_path.read_bytes() 319 | recorded_preimage = preimage_path.read_bytes() 320 | except IOError as err: 321 | print(f"(warning) failed to read git-rerere cache: {err}", file=sys.stderr) 322 | return (normalized_preimage, conflict_id, None) 323 | 324 | (is_clean_merge, merged) = merge_files( 325 | repo, 326 | labels=("recorded postimage", "recorded preimage", "new preimage"), 327 | current=recorded_postimage, 328 | base=recorded_preimage, 329 | other=normalized_preimage, 330 | tmpdir=tmpdir, 331 | ) 332 | if not is_clean_merge: 333 | # We could ask the user to merge this. However, that could be confusing. 334 | # Just fall back to letting them resolve the entire conflict. 335 | return (normalized_preimage, conflict_id, None) 336 | 337 | print("Successfully replayed recorded resolution") 338 | # Mark that "postimage" was used to help git gc. See merge() in Git's rerere.c. 339 | os.utime(postimage_path) 340 | return (normalized_preimage, conflict_id, Blob(repo, merged)) 341 | 342 | 343 | def record_resolution( 344 | repo: Repository, 345 | conflict_id: Optional[str], 346 | normalized_preimage: bytes, 347 | postimage: bytes, 348 | ) -> None: 349 | if conflict_id is None: 350 | return 351 | 352 | # TODO Lock {repo.gitdir}/MERGE_RR until everything is written. 353 | print("Recording conflict resolution") 354 | conflict_dir = repo.git_path("rr-cache") / conflict_id 355 | try: 356 | conflict_dir.mkdir(exist_ok=True, parents=True) 357 | (conflict_dir / "preimage").write_bytes(normalized_preimage) 358 | (conflict_dir / "postimage").write_bytes(postimage) 359 | except IOError as err: 360 | print(f"(warning) failed to write git-rerere cache: {err}", file=sys.stderr) 361 | 362 | 363 | class ConflictParseFailed(Exception): 364 | pass 365 | 366 | 367 | def normalize_conflict( 368 | lines: Iterator[bytes], 369 | hasher: Optional[hashlib._Hash], 370 | ) -> bytes: 371 | cur_hunk: Optional[bytes] = b"" 372 | other_hunk: Optional[bytes] = None 373 | while True: 374 | line = next(lines, None) 375 | if line is None: 376 | raise ConflictParseFailed("unexpected eof") 377 | if line.startswith(b"<<<<<<< "): 378 | # parse recursive conflicts, including their processed output in the current hunk 379 | conflict = normalize_conflict(lines, None) 380 | if cur_hunk is not None: 381 | cur_hunk += conflict 382 | elif line.startswith(b"|||||||"): 383 | # ignore the diff3 original section. Must be still parsing the first hunk. 384 | if other_hunk is not None: 385 | raise ConflictParseFailed("unexpected ||||||| conflict marker") 386 | (other_hunk, cur_hunk) = (cur_hunk, None) 387 | elif line.startswith(b"======="): 388 | # switch into the second hunk 389 | # could be in either the diff3 original section or the first hunk 390 | if cur_hunk is not None: 391 | if other_hunk is not None: 392 | raise ConflictParseFailed("unexpected ======= conflict marker") 393 | other_hunk = cur_hunk 394 | cur_hunk = b"" 395 | elif line.startswith(b">>>>>>> "): 396 | # end of conflict. update hasher, and return a normalized conflict 397 | if cur_hunk is None or other_hunk is None: 398 | raise ConflictParseFailed("unexpected >>>>>>> conflict marker") 399 | 400 | (hunk1, hunk2) = sorted((cur_hunk, other_hunk)) 401 | if hasher: 402 | hasher.update(hunk1 + b"\0") 403 | hasher.update(hunk2 + b"\0") 404 | return b"".join( 405 | ( 406 | b"<<<<<<<\n", 407 | hunk1, 408 | b"=======\n", 409 | hunk2, 410 | b">>>>>>>\n", 411 | ) 412 | ) 413 | elif cur_hunk is not None: 414 | # add non-marker lines to the current hunk (or discard if in 415 | # the diff3 original section) 416 | cur_hunk += line 417 | 418 | 419 | def normalize_conflicted_file(body: bytes) -> Tuple[bytes, str]: 420 | hasher = hashlib.sha1() 421 | normalized = b"" 422 | 423 | lines = iter(body.splitlines(keepends=True)) 424 | while True: 425 | line = next(lines, None) 426 | if line is None: 427 | return (normalized, hasher.hexdigest()) 428 | if line.startswith(b"<<<<<<< "): 429 | normalized += normalize_conflict(lines, hasher) 430 | else: 431 | normalized += line 432 | -------------------------------------------------------------------------------- /gitrevise/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum, auto 4 | import os 5 | import re 6 | import sys 7 | import textwrap 8 | from pathlib import Path 9 | from subprocess import CalledProcessError, run 10 | from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple 11 | 12 | from .odb import Commit, Oid, Reference, Repository, Tree 13 | 14 | if TYPE_CHECKING: 15 | from subprocess import CompletedProcess 16 | 17 | 18 | GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR = "------------------------ >8 ------------------------\n" 19 | 20 | 21 | class EditorCleanupMode(Enum): 22 | """git config commit.cleanup representation""" 23 | STRIP = auto() 24 | WHITESPACE = auto() 25 | VERBATIM = auto() 26 | SCISSORS = auto() 27 | DEFAULT = STRIP 28 | 29 | @property 30 | def comment(self) -> str: 31 | return { 32 | EditorCleanupMode.STRIP: ( 33 | "Please enter the commit message for your changes. Lines starting\n" 34 | "with '#' will be ignored, and an empty message aborts the commit.\n" 35 | ), 36 | EditorCleanupMode.SCISSORS: ( 37 | f"{GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR}" 38 | "Do not modify or remove the line above.\n" 39 | "Everything below it will be ignored.\n" 40 | ), 41 | }.get( 42 | self, 43 | ( 44 | "Please enter the commit message for your changes. Lines starting\n" 45 | "with '#' will be kept; you may remove them yourself if you want to.\n" 46 | "An empty message aborts the commit.\n" 47 | ) 48 | ) 49 | 50 | @classmethod 51 | def from_repository(cls, repo: Repository) -> EditorCleanupMode: 52 | cleanup_str = repo.config("commit.cleanup", default=b"default").decode() 53 | value = cls.__members__.get(cleanup_str.upper()) 54 | if value is None: 55 | raise ValueError(f"Invalid cleanup mode {cleanup_str}") 56 | return value 57 | 58 | 59 | class EditorError(Exception): 60 | pass 61 | 62 | 63 | def commit_range(base: Optional[Commit], tip: Commit) -> List[Commit]: 64 | """Oldest-first iterator over the given commit range, 65 | not including the commit ``base``""" 66 | commits = [] 67 | while tip != base: 68 | commits.append(tip) 69 | if tip.is_root and base is None: 70 | break 71 | tip = tip.parent() 72 | commits.reverse() 73 | return commits 74 | 75 | 76 | def local_commits(repo: Repository, tip: Commit) -> Tuple[Commit, List[Commit]]: 77 | """Returns an oldest-first iterator over the local commits which are 78 | parents of the specified commit. May return an empty list. A commit is 79 | considered local if it is not present on any remote.""" 80 | 81 | # Keep track of the current base commit we're expecting. This serves two 82 | # purposes. Firstly, it lets us return a base commit to our caller, and 83 | # secondly it allows us to ensure the commits ``git log`` is producing form 84 | # a single-parent chain from our initial commit. 85 | base = tip 86 | 87 | # Call `git log` to log out the OIDs of the commits in our specified range. 88 | log = repo.git("log", base.oid.hex(), "--not", "--remotes", "--pretty=%H") 89 | 90 | # Build a list of commits, validating each commit is part of a single-parent chain. 91 | commits = [] 92 | for line in log.splitlines(): 93 | commit = repo.get_commit(Oid.fromhex(line.decode())) 94 | 95 | # Ensure the commit we got is the parent of the previous logged commit. 96 | if len(commit.parents()) != 1 or commit != base: 97 | break 98 | base = commit.parent() 99 | 100 | # Add the commit to our list. 101 | commits.append(commit) 102 | 103 | # Reverse our list into oldest-first order. 104 | commits.reverse() 105 | return base, commits 106 | 107 | 108 | def edit_file_with_editor(editor: str, path: Path) -> bytes: 109 | try: 110 | cmd = [sh_path(), "-ec", f'{editor} "$@"', editor, str(path)] 111 | run(cmd, check=True) 112 | except CalledProcessError as err: 113 | raise EditorError(f"Editor exited with status {err}") from err 114 | return path.read_bytes() 115 | 116 | 117 | def get_commentchar(repo: Repository, text: bytes) -> bytes: 118 | commentchar = repo.config("core.commentChar", default=b"#") 119 | if commentchar == b"auto": 120 | chars = bytearray(b"#;@!$%^&|:") 121 | for line in text.splitlines(): 122 | try: 123 | chars.remove(line[0]) 124 | except (ValueError, IndexError): 125 | pass 126 | try: 127 | return chars[:1] 128 | except IndexError as err: 129 | raise EditorError( 130 | "Unable to automatically select a comment character" 131 | ) from err 132 | if commentchar == b"": 133 | raise EditorError("core.commentChar must not be empty") 134 | return commentchar 135 | 136 | 137 | def cut_after_scissors(lines: list[bytes], commentchar: bytes) -> list[bytes]: 138 | try: 139 | scissors = lines.index(commentchar + b" " + GIT_SCISSOR_LINE_WITHOUT_COMMENT_CHAR.encode()) 140 | except ValueError: 141 | scissors = None 142 | return lines[:scissors] 143 | 144 | 145 | def strip_comments(lines: list[bytes], commentchar: bytes, allow_preceding_whitespace: bool): 146 | if allow_preceding_whitespace: 147 | pat_is_comment_line = re.compile(rb"^\s*" + re.escape(commentchar)) 148 | 149 | def is_comment_line(line: bytes) -> bool: 150 | return bool(re.match(pat_is_comment_line, line)) 151 | 152 | else: 153 | def is_comment_line(line: bytes) -> bool: 154 | return line.startswith(commentchar) 155 | 156 | return [line for line in lines if not is_comment_line(line)] 157 | 158 | 159 | def cleanup_editor_content( 160 | data: bytes, 161 | commentchar: bytes, 162 | cleanup_mode: EditorCleanupMode, 163 | force_cut_after_scissors: bool = False, 164 | allow_preceding_whitespace: bool = False, 165 | ) -> bytes: 166 | lines_list = data.splitlines(keepends=True) 167 | 168 | # Force cut after scissors even in verbatim mode 169 | if force_cut_after_scissors or cleanup_mode == EditorCleanupMode.SCISSORS: 170 | lines_list = cut_after_scissors(lines_list, commentchar) 171 | 172 | if cleanup_mode == EditorCleanupMode.VERBATIM: 173 | return b"".join(lines_list) 174 | 175 | if cleanup_mode == EditorCleanupMode.STRIP: 176 | lines_list = strip_comments(lines_list, commentchar, allow_preceding_whitespace) 177 | 178 | # Remove trailing whitespace in each line 179 | lines_list = [line.rstrip() for line in lines_list] 180 | empty_lines = [not line for line in lines_list] + [True] 181 | 182 | # Remove leading empty lines 183 | try: 184 | start = empty_lines.index(False) 185 | except ValueError: 186 | start = None 187 | lines_list = lines_list[start:] 188 | empty_lines = empty_lines[start:] 189 | 190 | # Collapse consecutive empty lines 191 | lines_list = [ 192 | lines_list[cur] + b"\n" for cur in range(len(lines_list)) 193 | if not (empty_lines[cur] and empty_lines[cur + 1]) 194 | ] 195 | 196 | lines_bytes = b"".join(lines_list) 197 | 198 | return remove_trailing_empty_lines(lines_bytes) 199 | 200 | 201 | def remove_trailing_empty_lines(lines_bytes: bytes): 202 | lines_bytes = lines_bytes.rstrip() 203 | if lines_bytes != b"": 204 | lines_bytes += b"\n" 205 | return lines_bytes 206 | 207 | 208 | def run_specific_editor( 209 | editor: str, 210 | repo: Repository, 211 | filename: str, 212 | text: bytes, 213 | cleanup_mode: EditorCleanupMode, 214 | comments: Optional[str] = None, 215 | commit_diff: Optional[bytes] = None, 216 | allow_empty: bool = False, 217 | allow_whitespace_before_comments: bool = False, 218 | ) -> bytes: 219 | """Run the editor configured for git to edit the given text""" 220 | path = repo.get_tempdir() / filename 221 | commentchar = get_commentchar(repo, text) 222 | with open(path, "wb") as handle: 223 | for line in text.splitlines(): 224 | handle.write(line + b"\n") 225 | 226 | if comments: # If comments were provided, write them after the text. 227 | handle.write(b"\n") 228 | for comment in textwrap.dedent(comments).splitlines(): 229 | handle.write(commentchar) 230 | if comment: 231 | handle.write(b" " + comment.encode("utf-8")) 232 | handle.write(b"\n") 233 | 234 | if commit_diff: 235 | handle.write(commentchar + b"\n") 236 | lines = [commentchar + b" " + line.encode() for line in 237 | EditorCleanupMode.SCISSORS.comment.splitlines(keepends=True)] 238 | for line in lines: 239 | handle.write(line) 240 | handle.write(commit_diff) 241 | 242 | # Invoke the editor 243 | data = edit_file_with_editor(editor, path) 244 | data = cleanup_editor_content( 245 | data, 246 | commentchar, 247 | cleanup_mode, 248 | # If diff is appended then git always cuts after the scissors (even when commit.cleanup=verbatim) 249 | force_cut_after_scissors=commit_diff is not None, 250 | allow_preceding_whitespace=allow_whitespace_before_comments, 251 | ) 252 | 253 | # Produce an error if the file was empty 254 | if not (allow_empty or data): 255 | raise EditorError("empty file - aborting") 256 | return data 257 | 258 | 259 | def git_editor(repo: Repository) -> str: 260 | return repo.git("var", "GIT_EDITOR").decode() 261 | 262 | 263 | def edit_file(repo: Repository, path: Path) -> bytes: 264 | return edit_file_with_editor(git_editor(repo), path) 265 | 266 | 267 | def run_editor( 268 | repo: Repository, 269 | filename: str, 270 | text: bytes, 271 | cleanup_mode: EditorCleanupMode = EditorCleanupMode.DEFAULT, 272 | comments: Optional[str] = None, 273 | commit_diff: Optional[bytes] = None, 274 | allow_empty: bool = False, 275 | ) -> bytes: 276 | """Run the editor configured for git to edit the given text""" 277 | return run_specific_editor( 278 | editor=git_editor(repo), 279 | repo=repo, 280 | filename=filename, 281 | text=text, 282 | cleanup_mode=cleanup_mode, 283 | comments=comments, 284 | commit_diff=commit_diff, 285 | allow_empty=allow_empty, 286 | ) 287 | 288 | 289 | def git_sequence_editor(repo: Repository) -> str: 290 | # This lookup order replicates the one used by git itself. 291 | # See editor.c:sequence_editor. 292 | editor = os.getenv("GIT_SEQUENCE_EDITOR") 293 | if editor is None: 294 | editor_bytes = repo.config("sequence.editor", default=None) 295 | editor = editor_bytes.decode() if editor_bytes is not None else None 296 | if editor is None: 297 | editor = git_editor(repo) 298 | return editor 299 | 300 | 301 | def run_sequence_editor( 302 | repo: Repository, 303 | filename: str, 304 | text: bytes, 305 | comments: Optional[str] = None, 306 | allow_empty: bool = False, 307 | ) -> bytes: 308 | """Run the editor configured for git to edit the given rebase/revise sequence""" 309 | return run_specific_editor( 310 | editor=git_sequence_editor(repo), 311 | repo=repo, 312 | filename=filename, 313 | text=text, 314 | cleanup_mode=EditorCleanupMode.DEFAULT, 315 | comments=comments, 316 | allow_empty=allow_empty, 317 | allow_whitespace_before_comments=True, 318 | ) 319 | 320 | 321 | def edit_commit_message(commit: Commit) -> Commit: 322 | """Launch an editor to edit the commit message of ``commit``, returning 323 | a modified commit""" 324 | repo = commit.repo 325 | 326 | cleanup_mode = EditorCleanupMode.from_repository(repo) 327 | comments = cleanup_mode.comment 328 | commit_diff = None 329 | 330 | # If the target commit is not a merge commit, produce a diff --stat to 331 | # include in the commit message comments. 332 | if len(commit.parents()) < 2: 333 | tree_a = commit.parent_tree().persist().hex() 334 | tree_b = commit.tree().persist().hex() 335 | comments += "\n" + repo.git("diff-tree", "--stat", tree_a, tree_b).decode() 336 | verbose = repo.bool_config("commit.verbose", False) 337 | if verbose: 338 | commit_diff = repo.git("diff", tree_a, tree_b) 339 | 340 | message = run_editor( 341 | repo, 342 | "COMMIT_EDITMSG", 343 | commit.message, 344 | cleanup_mode, 345 | comments=comments, 346 | commit_diff=commit_diff, 347 | ) 348 | return commit.update(message=message) 349 | 350 | 351 | def update_head(ref: Reference[Commit], new: Commit, expected: Optional[Tree]) -> None: 352 | # Update the HEAD commit to point to the new value. 353 | target_oid = ref.target.oid if ref.target else Oid.null() 354 | print(f"Updating {ref.name} ({target_oid} => {new.oid})") 355 | ref.update(new, "git-revise rewrite") 356 | 357 | # We expect our tree to match the tree we started with (including index 358 | # changes). If it does not, print out a warning. 359 | if expected and new.tree() != expected: 360 | print( 361 | "(warning) unexpected final tree\n" 362 | f"(note) expected: {expected.oid}\n" 363 | f"(note) actual: {new.tree().oid}\n" 364 | "(note) working directory & index have not been updated.\n" 365 | "(note) use `git status` to see what has changed.", 366 | file=sys.stderr, 367 | ) 368 | 369 | 370 | def cut_commit(commit: Commit) -> Commit: 371 | """Perform a ``cut`` operation on the given commit, and return the 372 | modified commit.""" 373 | 374 | print(f"Cutting commit {commit.oid.short()}") 375 | print("Select changes to be included in part [1]:") 376 | 377 | base_tree = commit.parent_tree() 378 | final_tree = commit.tree() 379 | 380 | # Create an environment with an explicit index file and the base tree. 381 | # 382 | # NOTE: The use of `skip_worktree` is only necessary due to `git reset 383 | # --patch` unnecessarily invoking `git update-cache --refresh`. Doing the 384 | # extra work to set the bit greatly improves the speed of the unnecessary 385 | # refresh operation. 386 | index = base_tree.to_index( 387 | commit.repo.get_tempdir() / "TEMP_INDEX", skip_worktree=True 388 | ) 389 | 390 | # Run an interactive git-reset to allow picking which pieces of the 391 | # patch should go into the first part. 392 | index.git("reset", "--patch", final_tree.persist().hex(), "--", ".", stdout=None) 393 | 394 | # Write out the newly created tree. 395 | mid_tree = index.tree() 396 | 397 | # Check if one or the other of the commits will be empty 398 | if mid_tree == base_tree: 399 | raise ValueError("cut part [1] is empty - aborting") 400 | 401 | if mid_tree == final_tree: 402 | raise ValueError("cut part [2] is empty - aborting") 403 | 404 | # Build the first commit 405 | part1 = commit.update(tree=mid_tree, message=b"[1] " + commit.message) 406 | part1 = edit_commit_message(part1) 407 | 408 | # Build the second commit 409 | part2 = commit.update(parents=[part1], message=b"[2] " + commit.message) 410 | part2 = edit_commit_message(part2) 411 | 412 | return part2 413 | 414 | 415 | def sh_path() -> str: 416 | if os.name == "nt": 417 | # On Windows, git is installed using Git for Windows, which installs 418 | # into the "Git" directory in "%ProgramFiles%". Use the `sh.exe` file 419 | # from that directory to perform shell operations, so they're executed 420 | # in the expected environment. 421 | return os.path.join(os.environ["PROGRAMFILES"], "Git", "bin", "sh.exe") 422 | return "/bin/sh" 423 | 424 | 425 | def sh_run( 426 | cmd: Sequence[Any], 427 | *args: Any, 428 | **kwargs: Any, 429 | ) -> "CompletedProcess[Any]": 430 | """Run a command within git's shell environment. This is the same as 431 | subprocess.run on most platforms, but will enter the git-bash mingw 432 | environment on Windows.""" 433 | if os.name == "nt": 434 | cmd = (sh_path(), "-ec", 'exec "$0" "$@"', *cmd) 435 | return run(cmd, *args, **kwargs) # pylint: disable=subprocess-run-check 436 | -------------------------------------------------------------------------------- /gitrevise/odb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper classes for reading cached objects from Git's Object Database. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import hashlib 8 | import os 9 | import re 10 | import sys 11 | from collections import defaultdict 12 | from enum import Enum 13 | from pathlib import Path 14 | from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, run 15 | from tempfile import TemporaryDirectory 16 | from types import TracebackType 17 | from typing import ( 18 | TYPE_CHECKING, 19 | Dict, 20 | Generic, 21 | Mapping, 22 | Optional, 23 | Sequence, 24 | Tuple, 25 | Type, 26 | TypeVar, 27 | Union, 28 | cast, 29 | ) 30 | 31 | if TYPE_CHECKING: 32 | from subprocess import _FILE 33 | 34 | from typing_extensions import Self 35 | 36 | 37 | class MissingObject(Exception): 38 | """Exception raised when a commit cannot be found in the ODB""" 39 | 40 | def __init__(self, ref: str) -> None: 41 | Exception.__init__(self, f"Object {ref} does not exist") 42 | 43 | 44 | class GPGSignError(Exception): 45 | """Exception raised when we fail to sign a commit""" 46 | 47 | def __init__(self, stderr: str) -> None: 48 | Exception.__init__(self, f"unable to sign object: {stderr}") 49 | 50 | 51 | T = TypeVar("T") # pylint: disable=invalid-name 52 | 53 | 54 | class Oid(bytes): 55 | """Git object identifier""" 56 | 57 | __slots__ = () 58 | 59 | def __new__(cls, b: bytes) -> Oid: 60 | if len(b) != 20: 61 | raise ValueError("Expected 160-bit SHA1 hash") 62 | return super().__new__(cls, b) 63 | 64 | @classmethod 65 | def fromhex(cls, instr: str) -> Oid: 66 | """Parse an ``Oid`` from a hexadecimal string""" 67 | return Oid(bytes.fromhex(instr)) 68 | 69 | @classmethod 70 | def null(cls) -> Oid: 71 | """An ``Oid`` consisting of entirely 0s""" 72 | return cls(b"\0" * 20) 73 | 74 | def short(self) -> str: 75 | """A shortened version of the Oid's hexadecimal form""" 76 | return str(self)[:12] 77 | 78 | @classmethod 79 | def for_object(cls, tag: str, body: bytes) -> Oid: 80 | """Hash an object with the given type tag and body to determine its Oid""" 81 | hasher = hashlib.sha1() 82 | hasher.update(tag.encode() + b" " + str(len(body)).encode() + b"\0" + body) 83 | return cls(hasher.digest()) 84 | 85 | def __repr__(self) -> str: 86 | return self.hex() 87 | 88 | def __str__(self) -> str: 89 | return self.hex() 90 | 91 | 92 | class Signature(bytes): 93 | """Git user signature""" 94 | 95 | __slots__ = () 96 | 97 | sig_re = re.compile( 98 | rb""" 99 | (?P 100 | (?P[^<>]+)<(?P[^<>]+)> 101 | ) 102 | [ ] 103 | (?P[0-9]+) 104 | (?:[ ](?P[\+\-][0-9]+))? 105 | """, 106 | re.X, 107 | ) 108 | 109 | @property 110 | def name(self) -> bytes: 111 | """user name""" 112 | match = self.sig_re.fullmatch(self) 113 | assert match, "invalid signature" 114 | return match.group("name").strip() 115 | 116 | @property 117 | def email(self) -> bytes: 118 | """user email""" 119 | match = self.sig_re.fullmatch(self) 120 | assert match, "invalid signature" 121 | return match.group("email").strip() 122 | 123 | @property 124 | def signing_key(self) -> bytes: 125 | """user name """ 126 | match = self.sig_re.fullmatch(self) 127 | assert match, "invalid signature" 128 | return match.group("signing_key").strip() 129 | 130 | @property 131 | def timestamp(self) -> bytes: 132 | """unix timestamp""" 133 | match = self.sig_re.fullmatch(self) 134 | assert match, "invalid signature" 135 | return match.group("timestamp").strip() 136 | 137 | @property 138 | def offset(self) -> bytes: 139 | """timezone offset from UTC""" 140 | match = self.sig_re.fullmatch(self) 141 | assert match, "invalid signature" 142 | return match.group("offset").strip() 143 | 144 | 145 | class Repository: 146 | """Main entry point for a git repository""" 147 | 148 | workdir: Path 149 | """working directory for this repository""" 150 | 151 | gitdir: Path 152 | """.git directory for this repository""" 153 | 154 | default_author: Signature 155 | """author used by default for new commits""" 156 | 157 | default_committer: Signature 158 | """committer used by default for new commits""" 159 | 160 | index: Index 161 | """current index state""" 162 | 163 | sign_commits: bool 164 | """sign commits with gpg""" 165 | 166 | gpg: bytes 167 | """path to GnuPG binary""" 168 | 169 | _objects: Dict[int, Dict[Oid, GitObj]] 170 | _catfile: Popen[bytes] 171 | _tempdir: Optional[TemporaryDirectory[str]] 172 | 173 | __slots__ = [ 174 | "workdir", 175 | "gitdir", 176 | "default_author", 177 | "default_committer", 178 | "index", 179 | "sign_commits", 180 | "gpg", 181 | "_objects", 182 | "_catfile", 183 | "_tempdir", 184 | ] 185 | 186 | def __init__(self, cwd: Optional[Path] = None) -> None: 187 | self._tempdir = None 188 | 189 | self.workdir = Path(self.git("rev-parse", "--show-toplevel", cwd=cwd).decode()) 190 | self.gitdir = self.workdir / Path(self.git("rev-parse", "--git-dir").decode()) 191 | 192 | # XXX(nika): Does it make more sense to cache these or call every time? 193 | # Cache for length of time & invalidate? 194 | self.default_author = Signature(self.git("var", "GIT_AUTHOR_IDENT")) 195 | self.default_committer = Signature(self.git("var", "GIT_COMMITTER_IDENT")) 196 | 197 | self.index = Index(self) 198 | 199 | self.sign_commits = self.bool_config( 200 | "revise.gpgSign", default=self.bool_config("commit.gpgSign", default=False) 201 | ) 202 | 203 | self.gpg = self.config("gpg.program", default=b"gpg") 204 | 205 | # Pylint 2.8 emits a false positive; fixed in 2.9. 206 | self._catfile = Popen( # pylint: disable=consider-using-with 207 | ["git", "cat-file", "--batch"], 208 | bufsize=-1, 209 | stdin=PIPE, 210 | stdout=PIPE, 211 | cwd=self.workdir, 212 | ) 213 | self._objects = defaultdict(dict) 214 | 215 | # Check that cat-file works OK 216 | try: 217 | self.get_obj(Oid.null()) 218 | raise IOError("cat-file backend failure") 219 | except MissingObject: 220 | pass 221 | 222 | def git( 223 | self, 224 | *cmd: str, 225 | cwd: Optional[Path] = None, 226 | env: Optional[Dict[str, str]] = None, 227 | stdin: Optional[bytes] = None, 228 | stdout: _FILE = PIPE, 229 | trim_newline: bool = True, 230 | ) -> bytes: 231 | if cwd is None: 232 | cwd = getattr(self, "workdir", None) 233 | 234 | cmd = ("git",) + cmd 235 | prog = run( 236 | cmd, 237 | cwd=cwd, 238 | env=env, 239 | input=stdin, 240 | stdout=stdout, 241 | check=True, 242 | ) 243 | 244 | if trim_newline and isinstance(prog.stdout, bytes): 245 | if prog.stdout.endswith(b"\n"): 246 | return prog.stdout[:-1] 247 | return prog.stdout 248 | 249 | def config(self, setting: str, default: T) -> Union[bytes, T]: 250 | try: 251 | return self.git("config", "--get", setting) 252 | except CalledProcessError: 253 | return default 254 | 255 | def bool_config(self, config: str, default: T) -> Union[bool, T]: 256 | try: 257 | return self.git("config", "--get", "--bool", config) == b"true" 258 | except CalledProcessError: 259 | return default 260 | 261 | def int_config(self, config: str, default: T) -> Union[int, T]: 262 | try: 263 | return int(self.git("config", "--get", "--int", config)) 264 | except CalledProcessError: 265 | return default 266 | 267 | def __enter__(self) -> Repository: 268 | return self 269 | 270 | def __exit__( 271 | self, 272 | exc_type: Optional[Type[BaseException]], 273 | exc_val: Optional[Exception], 274 | exc_tb: Optional[TracebackType], 275 | ) -> None: 276 | if self._tempdir: 277 | self._tempdir.__exit__(exc_type, exc_val, exc_tb) 278 | 279 | self._catfile.terminate() 280 | self._catfile.wait() 281 | 282 | def get_tempdir(self) -> Path: 283 | """Return a temporary directory to use for modifications to this repository""" 284 | if self._tempdir is None: 285 | # Pylint 2.8 emits a false positive; fixed in 2.9. 286 | self._tempdir = TemporaryDirectory( # pylint: disable=consider-using-with 287 | prefix="revise.", dir=str(self.gitdir) 288 | ) 289 | return Path(self._tempdir.name) 290 | 291 | def git_path(self, path: Union[str, Path]) -> Path: 292 | """Get the path to a file in the .git directory, respecting the environment""" 293 | return self.workdir / self.git("rev-parse", "--git-path", str(path)).decode() 294 | 295 | def new_commit( 296 | self, 297 | tree: Tree, 298 | parents: Sequence[Commit], 299 | message: bytes, 300 | author: Optional[Signature] = None, 301 | committer: Optional[Signature] = None, 302 | ) -> Commit: 303 | """Directly create an in-memory commit object, without persisting it. 304 | If a commit object with these properties already exists, it will be 305 | returned instead.""" 306 | if author is None: 307 | author = self.default_author 308 | if committer is None: 309 | committer = self.default_committer 310 | 311 | body = b"tree " + tree.oid.hex().encode() + b"\n" 312 | for parent in parents: 313 | body += b"parent " + parent.oid.hex().encode() + b"\n" 314 | body += b"author " + author + b"\n" 315 | body += b"committer " + committer + b"\n" 316 | 317 | body_tail = b"\n" + message 318 | body += self.sign_buffer(body + body_tail) 319 | body += body_tail 320 | 321 | return Commit(self, body) 322 | 323 | def sign_buffer(self, buffer: bytes) -> bytes: 324 | """Return the text of the signed commit object.""" 325 | from .utils import sh_run # pylint: disable=import-outside-toplevel 326 | 327 | if not self.sign_commits: 328 | return b"" 329 | 330 | key_id = self.config( 331 | "user.signingKey", default=self.default_committer.signing_key 332 | ) 333 | gpg = None 334 | try: 335 | gpg = sh_run( 336 | (self.gpg, "--status-fd=2", "-bsau", key_id), 337 | stdout=PIPE, 338 | stderr=PIPE, 339 | input=buffer, 340 | check=True, 341 | ) 342 | except CalledProcessError as gpg: 343 | print(gpg.stderr.decode(), file=sys.stderr, end="") 344 | print("gpg failed to sign commit", file=sys.stderr) 345 | raise 346 | 347 | if b"\n[GNUPG:] SIG_CREATED " not in gpg.stderr: 348 | raise GPGSignError(gpg.stderr.decode()) 349 | 350 | signature = b"gpgsig" 351 | for line in gpg.stdout.splitlines(): 352 | signature += b" " + line + b"\n" 353 | return signature 354 | 355 | def new_tree(self, entries: Mapping[bytes, Entry]) -> Tree: 356 | """Directly create an in-memory tree object, without persisting it. 357 | If a tree object with these entries already exists, it will be 358 | returned instead.""" 359 | 360 | def entry_key(pair: Tuple[bytes, Entry]) -> bytes: 361 | name, entry = pair 362 | # Directories are sorted in the tree listing as though they have a 363 | # trailing slash in their name. 364 | if entry.mode == Mode.DIR: 365 | return name + b"/" 366 | return name 367 | 368 | body = b"" 369 | for name, entry in sorted(entries.items(), key=entry_key): 370 | body += cast(bytes, entry.mode.value) + b" " + name + b"\0" + entry.oid 371 | return Tree(self, body) 372 | 373 | def get_obj(self, ref: Union[Oid, str]) -> GitObj: 374 | """Get the identified git object from this repository. If given an 375 | :class:`Oid`, the cache will be checked before asking git.""" 376 | if isinstance(ref, Oid): 377 | cache = self._objects[ref[0]] 378 | if ref in cache: 379 | return cache[ref] 380 | ref = ref.hex() 381 | 382 | # Satisfy mypy: otherwise these are Optional[IO[Any]]. 383 | (stdin, stdout) = (self._catfile.stdin, self._catfile.stdout) 384 | assert stdin is not None 385 | assert stdout is not None 386 | 387 | # Write out an object descriptor. 388 | stdin.write(ref.encode() + b"\n") 389 | stdin.flush() 390 | 391 | # Read in the response. 392 | resp = stdout.readline().decode() 393 | if resp.endswith("missing\n"): 394 | # If we have an abbreviated hash, check for in-memory commits. 395 | try: 396 | abbrev = bytes.fromhex(ref) 397 | for oid, obj in self._objects[abbrev[0]].items(): 398 | if oid.startswith(abbrev): 399 | return obj 400 | except (ValueError, IndexError): 401 | pass 402 | 403 | # Not an abbreviated hash, the entry is missing. 404 | raise MissingObject(ref) 405 | 406 | parts = resp.rsplit(maxsplit=2) 407 | oid, kind, size = Oid.fromhex(parts[0]), parts[1], int(parts[2]) 408 | body = stdout.read(size + 1)[:-1] 409 | assert size == len(body), "bad size?" 410 | 411 | # Create a corresponding git object. This will re-use the item in the 412 | # cache, if found, and add the item to the cache otherwise. 413 | if kind == "commit": 414 | obj = Commit(self, body) 415 | elif kind == "tree": 416 | obj = Tree(self, body) 417 | elif kind == "blob": 418 | obj = Blob(self, body) 419 | else: 420 | raise ValueError(f"Unknown object kind: {kind}") 421 | 422 | obj.persisted = True 423 | assert obj.oid == oid, "miscomputed oid" 424 | return obj 425 | 426 | def get_commit(self, ref: Union[Oid, str]) -> Commit: 427 | """Like :py:meth:`get_obj`, but returns a :class:`Commit`""" 428 | obj = self.get_obj(ref) 429 | if isinstance(obj, Commit): 430 | return obj 431 | raise ValueError(f"{type(obj).__name__} {ref} is not a Commit!") 432 | 433 | def get_tree(self, ref: Union[Oid, str]) -> Tree: 434 | """Like :py:meth:`get_obj`, but returns a :class:`Tree`""" 435 | obj = self.get_obj(ref) 436 | if isinstance(obj, Tree): 437 | return obj 438 | raise ValueError(f"{type(obj).__name__} {ref} is not a Tree!") 439 | 440 | def get_blob(self, ref: Union[Oid, str]) -> Blob: 441 | """Like :py:meth:`get_obj`, but returns a :class:`Blob`""" 442 | obj = self.get_obj(ref) 443 | if isinstance(obj, Blob): 444 | return obj 445 | raise ValueError(f"{type(obj).__name__} {ref} is not a Blob!") 446 | 447 | def get_obj_ref(self, ref: str) -> Reference[GitObj]: 448 | """Get a :class:`Reference` to a :class:`GitObj`""" 449 | return Reference(GitObj, self, ref) 450 | 451 | def get_commit_ref(self, ref: str) -> Reference[Commit]: 452 | """Get a :class:`Reference` to a :class:`Commit`""" 453 | return Reference(Commit, self, ref) 454 | 455 | def get_tree_ref(self, ref: str) -> Reference[Tree]: 456 | """Get a :class:`Reference` to a :class:`Tree`""" 457 | return Reference(Tree, self, ref) 458 | 459 | def get_blob_ref(self, ref: str) -> Reference[Blob]: 460 | """Get a :class:`Reference` to a :class:`Blob`""" 461 | return Reference(Blob, self, ref) 462 | 463 | 464 | GitObjT = TypeVar("GitObjT", bound="GitObj") 465 | 466 | 467 | class GitObj: 468 | """In-memory representation of a git object. Instances of this object 469 | should be one of :class:`Commit`, :class:`Tree` or :class:`Blob`""" 470 | 471 | repo: Repository 472 | """:class:`Repository` object is associated with""" 473 | 474 | body: bytes 475 | """Raw body of object in bytes""" 476 | 477 | oid: Oid 478 | """:class:`Oid` of this git object""" 479 | 480 | persisted: bool 481 | """If ``True``, the object has been persisted to disk""" 482 | 483 | __slots__ = ("repo", "body", "oid", "persisted") 484 | 485 | def __new__(cls, repo: Repository, body: bytes) -> "Self": 486 | oid = Oid.for_object(cls._git_type(), body) 487 | cache = repo._objects[oid[0]] # pylint: disable=protected-access 488 | if oid in cache: 489 | cached = cache[oid] 490 | assert isinstance(cached, cls) 491 | return cached 492 | 493 | self = super().__new__(cls) 494 | self.repo = repo 495 | self.body = body 496 | self.oid = oid 497 | self.persisted = False 498 | cache[oid] = self 499 | self._parse_body() # pylint: disable=protected-access 500 | return self 501 | 502 | @classmethod 503 | def _git_type(cls) -> str: 504 | return cls.__name__.lower() 505 | 506 | def persist(self) -> Oid: 507 | """If this object has not been persisted to disk yet, persist it""" 508 | if self.persisted: 509 | return self.oid 510 | 511 | self._persist_deps() 512 | new_oid = self.repo.git( 513 | "hash-object", 514 | "--no-filters", 515 | "-t", 516 | self._git_type(), 517 | "-w", 518 | "--stdin", 519 | stdin=self.body, 520 | ) 521 | 522 | assert Oid.fromhex(new_oid.decode()) == self.oid 523 | self.persisted = True 524 | return self.oid 525 | 526 | def _persist_deps(self) -> None: 527 | pass 528 | 529 | def _parse_body(self) -> None: 530 | pass 531 | 532 | def __eq__(self, other: object) -> bool: 533 | if isinstance(other, GitObj): 534 | return self.oid == other.oid 535 | return False 536 | 537 | 538 | class Commit(GitObj): 539 | """In memory representation of a git ``commit`` object""" 540 | 541 | tree_oid: Oid 542 | """:class:`Oid` of this commit's ``tree`` object""" 543 | 544 | parent_oids: Sequence[Oid] 545 | """List of :class:`Oid` for this commit's parents""" 546 | 547 | author: Signature 548 | """:class:`Signature` of this commit's author""" 549 | 550 | committer: Signature 551 | """:class:`Signature` of this commit's committer""" 552 | 553 | gpgsig: Optional[bytes] 554 | """GPG signature of this commit""" 555 | 556 | message: bytes 557 | """Body of this commit's message""" 558 | 559 | __slots__ = ("tree_oid", "parent_oids", "author", "committer", "gpgsig", "message") 560 | 561 | def _parse_body(self) -> None: 562 | # Split the header from the core commit message. 563 | hdrs, self.message = self.body.split(b"\n\n", maxsplit=1) 564 | 565 | # Parse the header to populate header metadata fields. 566 | self.parent_oids = [] 567 | for hdr in re.split(rb"\n(?! )", hdrs): 568 | # Parse out the key-value pairs from the header, handling 569 | # continuation lines. 570 | key, value = hdr.split(maxsplit=1) 571 | value = value.replace(b"\n ", b"\n") 572 | 573 | self.gpgsig = None 574 | if key == b"tree": 575 | self.tree_oid = Oid.fromhex(value.decode()) 576 | elif key == b"parent": 577 | self.parent_oids.append(Oid.fromhex(value.decode())) 578 | elif key == b"author": 579 | self.author = Signature(value) 580 | elif key == b"committer": 581 | self.committer = Signature(value) 582 | elif key == b"gpgsig": 583 | self.gpgsig = value 584 | 585 | def tree(self) -> Tree: 586 | """``tree`` object corresponding to this commit""" 587 | return self.repo.get_tree(self.tree_oid) 588 | 589 | def parent_tree(self) -> Tree: 590 | """``tree`` object corresponding to the first parent of this commit, 591 | or the null tree if this is a root commit""" 592 | if self.is_root: 593 | return Tree(self.repo, b"") 594 | return self.parents()[0].tree() 595 | 596 | @property 597 | def is_root(self) -> bool: 598 | """Whether this commit has no parents""" 599 | return not self.parent_oids 600 | 601 | def parents(self) -> Sequence[Commit]: 602 | """List of parent commits""" 603 | return [self.repo.get_commit(parent) for parent in self.parent_oids] 604 | 605 | def parent(self) -> Commit: 606 | """Helper method to get the single parent of a commit. Raises 607 | :class:`ValueError` if the incorrect number of parents are 608 | present.""" 609 | if len(self.parents()) != 1: 610 | raise ValueError(f"Commit {self.oid} has {len(self.parents())} parents") 611 | return self.parents()[0] 612 | 613 | def summary(self) -> str: 614 | """The summary line of the commit message. Returns the summary 615 | as a single line, even if it spans multiple lines.""" 616 | summary_paragraph = self.message.split(b"\n\n", maxsplit=1)[0].decode( 617 | errors="replace" 618 | ) 619 | return " ".join(summary_paragraph.splitlines()) 620 | 621 | def rebase(self, parent: Optional[Commit]) -> Commit: 622 | """Create a new commit with the same changes, except with ``parent`` 623 | as its parent. If ``parent`` is ``None``, this becomes a root commit.""" 624 | from .merge import rebase # pylint: disable=import-outside-toplevel 625 | 626 | return rebase(self, parent) 627 | 628 | def update( 629 | self, 630 | tree: Optional[Tree] = None, 631 | parents: Optional[Sequence[Commit]] = None, 632 | message: Optional[bytes] = None, 633 | author: Optional[Signature] = None, 634 | recommit: bool = False, 635 | ) -> Commit: 636 | """Create a new commit with specific properties updated or replaced""" 637 | # Compute parameters used to create the new object. 638 | if tree is None: 639 | tree = self.tree() 640 | if parents is None: 641 | parents = self.parents() 642 | if message is None: 643 | message = self.message 644 | if author is None: 645 | author = self.author 646 | 647 | if not recommit: 648 | # Check if the commit was unchanged to avoid creating a new commit if 649 | # only the committer has changed. 650 | unchanged = ( 651 | tree == self.tree() 652 | and parents == self.parents() 653 | and message == self.message 654 | and author == self.author 655 | ) 656 | if unchanged: 657 | return self 658 | 659 | return self.repo.new_commit(tree, parents, message, author) 660 | 661 | def _persist_deps(self) -> None: 662 | self.tree().persist() 663 | for parent in self.parents(): 664 | parent.persist() 665 | 666 | def __repr__(self) -> str: 667 | return ( 668 | f"" 671 | ) 672 | 673 | 674 | class Mode(Enum): 675 | """Mode for an entry in a ``tree``""" 676 | 677 | GITLINK = b"160000" 678 | """submodule entry""" 679 | 680 | SYMLINK = b"120000" 681 | """symlink entry""" 682 | 683 | DIR = b"40000" 684 | """directory entry""" 685 | 686 | REGULAR = b"100644" 687 | """regular entry""" 688 | 689 | EXEC = b"100755" 690 | """executable entry""" 691 | 692 | def is_file(self) -> bool: 693 | return self in (Mode.REGULAR, Mode.EXEC) 694 | 695 | def comparable_to(self, other: Mode) -> bool: 696 | return self == other or (self.is_file() and other.is_file()) 697 | 698 | 699 | class Entry: 700 | """In memory representation of a single ``tree`` entry""" 701 | 702 | repo: Repository 703 | """:class:`Repository` this entry originates from""" 704 | 705 | mode: Mode 706 | """:class:`Mode` of the entry""" 707 | 708 | oid: Oid 709 | """:class:`Oid` of this entry's object""" 710 | 711 | __slots__ = ("repo", "mode", "oid") 712 | 713 | def __init__(self, repo: Repository, mode: Mode, oid: Oid) -> None: 714 | self.repo = repo 715 | self.mode = mode 716 | self.oid = oid 717 | 718 | def blob(self) -> Blob: 719 | """Get the data for this entry as a :class:`Blob`""" 720 | if self.mode in (Mode.REGULAR, Mode.EXEC): 721 | return self.repo.get_blob(self.oid) 722 | return Blob(self.repo, b"") 723 | 724 | def symlink(self) -> bytes: 725 | """Get the data for this entry as a symlink""" 726 | if self.mode == Mode.SYMLINK: 727 | return self.repo.get_blob(self.oid).body 728 | return b"" 729 | 730 | def tree(self) -> Tree: 731 | """Get the data for this entry as a :class:`Tree`""" 732 | if self.mode == Mode.DIR: 733 | return self.repo.get_tree(self.oid) 734 | return Tree(self.repo, b"") 735 | 736 | def persist(self) -> None: 737 | """:py:meth:`GitObj.persist` the git object referenced by this entry""" 738 | if self.mode != Mode.GITLINK: 739 | self.repo.get_obj(self.oid).persist() 740 | 741 | def __repr__(self) -> str: 742 | return f"" 743 | 744 | def __eq__(self, other: object) -> bool: 745 | if isinstance(other, Entry): 746 | return self.mode == other.mode and self.oid == other.oid 747 | return False 748 | 749 | 750 | class Tree(GitObj): 751 | """In memory representation of a git ``tree`` object""" 752 | 753 | entries: Dict[bytes, Entry] 754 | """mapping from entry names to entry objects in this tree""" 755 | 756 | __slots__ = ("entries",) 757 | 758 | def _parse_body(self) -> None: 759 | self.entries = {} 760 | rest = self.body 761 | while rest: 762 | mode, rest = rest.split(b" ", maxsplit=1) 763 | name, rest = rest.split(b"\0", maxsplit=1) 764 | entry_oid = Oid(rest[:20]) 765 | rest = rest[20:] 766 | self.entries[name] = Entry(self.repo, Mode(mode), entry_oid) 767 | 768 | def _persist_deps(self) -> None: 769 | for entry in self.entries.values(): 770 | entry.persist() 771 | 772 | def to_index(self, path: Path, skip_worktree: bool = False) -> Index: 773 | """Read tree into a temporary index. If skip_workdir is ``True``, every 774 | entry in the index will have its "Skip Workdir" bit set.""" 775 | 776 | index = Index(self.repo, path) 777 | self.repo.git( 778 | "read-tree", 779 | "--index-output=" + str(path), 780 | self.persist().hex(), 781 | stdout=DEVNULL, 782 | ) 783 | 784 | # If skip_worktree is set, mark every file as --skip-worktree. 785 | if skip_worktree: 786 | # XXX(nika): Could be done with a pipe, which might improve perf. 787 | files = index.git("ls-files") 788 | index.git( 789 | "update-index", 790 | "--skip-worktree", 791 | "--stdin", 792 | stdin=files, 793 | stdout=DEVNULL, 794 | ) 795 | 796 | return index 797 | 798 | def __repr__(self) -> str: 799 | return f"" 800 | 801 | 802 | class Blob(GitObj): 803 | """In memory representation of a git ``blob`` object""" 804 | 805 | __slots__ = () 806 | 807 | def __repr__(self) -> str: 808 | return f"" 809 | 810 | 811 | class Index: 812 | """Handle on an index file""" 813 | 814 | repo: Repository 815 | """""" 816 | 817 | index_file: Path 818 | """Index file being referenced""" 819 | 820 | def __init__(self, repo: Repository, index_file: Optional[Path] = None) -> None: 821 | self.repo = repo 822 | 823 | if index_file is None: 824 | index_file = self.repo.git_path("index") 825 | self.index_file = index_file 826 | 827 | assert self.git("rev-parse", "--git-path", "index").decode() == str(index_file) 828 | 829 | def git( 830 | self, 831 | *cmd: str, 832 | cwd: Optional[Path] = None, 833 | env: Optional[Mapping[str, str]] = None, 834 | stdin: Optional[bytes] = None, 835 | stdout: _FILE = PIPE, 836 | trim_newline: bool = True, 837 | ) -> bytes: 838 | """Invoke git with the given index as active""" 839 | env = {**env} if env is not None else {**os.environ} 840 | env["GIT_INDEX_FILE"] = str(self.index_file) 841 | return self.repo.git( 842 | *cmd, 843 | cwd=cwd, 844 | env=env, 845 | stdin=stdin, 846 | stdout=stdout, 847 | trim_newline=trim_newline, 848 | ) 849 | 850 | def tree(self) -> Tree: 851 | """Get a :class:`Tree` object for this index's state""" 852 | oid = Oid.fromhex(self.git("write-tree").decode()) 853 | return self.repo.get_tree(oid) 854 | 855 | def commit( 856 | self, message: bytes = b"", parent: Optional[Commit] = None 857 | ) -> Commit: 858 | """Get a :class:`Commit` for this index's state. If ``parent`` is 859 | ``None``, use the current ``HEAD``""" 860 | 861 | if parent is None: 862 | parent = self.repo.get_commit("HEAD") 863 | 864 | return self.repo.new_commit(self.tree(), [parent], message) 865 | 866 | 867 | class Reference(Generic[GitObjT]): # pylint: disable=unsubscriptable-object 868 | """A git reference""" 869 | 870 | shortname: str 871 | """Short unresolved reference name, e.g. 'HEAD' or 'master'""" 872 | 873 | name: str 874 | """Resolved reference name, e.g. 'refs/tags/1.0.0' or 'refs/heads/master'""" 875 | 876 | target: Optional[GitObjT] 877 | """Referenced git object""" 878 | 879 | repo: Repository 880 | """Repository reference is attached to""" 881 | 882 | _type: Type[GitObjT] 883 | 884 | # FIXME: On python 3.6, pylint doesn't know what to do with __slots__ here. 885 | # __slots__ = ("name", "target", "repo", "_type") 886 | 887 | def __init__(self, obj_type: Type[GitObjT], repo: Repository, name: str) -> None: 888 | self._type = obj_type 889 | 890 | self.name = name 891 | try: 892 | # Silently verify that a ref with the name exists and recover if it 893 | # doesn't. 894 | repo.git("show-ref", "--quiet", "--verify", self.name) 895 | except CalledProcessError: 896 | # `name` could be a branch name which can be resolved to a ref. Try 897 | # to do so with `rev-parse`, and verify that the new name exists. 898 | self.name = repo.git( 899 | "rev-parse", "--symbolic-full-name", self.name 900 | ).decode() 901 | repo.git("show-ref", "--verify", self.name) 902 | 903 | self.repo = repo 904 | self.refresh() 905 | 906 | def refresh(self) -> None: 907 | """Re-read the target of this reference from disk""" 908 | try: 909 | obj = self.repo.get_obj(self.name) 910 | 911 | if not isinstance(obj, self._type): 912 | raise ValueError( 913 | f"{type(obj).__name__} {self.name} is not a {self._type.__name__}!" 914 | ) 915 | 916 | self.target = obj 917 | except MissingObject: 918 | self.target = None 919 | 920 | def update(self, new: GitObjT, reason: str) -> None: 921 | """Update this refreence to point to a new object. 922 | An entry with the reason ``reason`` will be added to the reflog.""" 923 | new.persist() 924 | args = ["update-ref", "-m", reason, self.name, str(new.oid)] 925 | if self.target is not None: 926 | args.append(str(self.target.oid)) 927 | 928 | self.repo.git(*args, stdout=DEVNULL) 929 | self.target = new 930 | --------------------------------------------------------------------------------