pax_global_header00006660000000000000000000000064136672576160014535gustar00rootroot0000000000000052 comment=cbe8c162b85b0b57c3bd71c204ee091afe3d9336 git-revise-0.6.0/000077500000000000000000000000001366725761600136165ustar00rootroot00000000000000git-revise-0.6.0/.github/000077500000000000000000000000001366725761600151565ustar00rootroot00000000000000git-revise-0.6.0/.github/workflows/000077500000000000000000000000001366725761600172135ustar00rootroot00000000000000git-revise-0.6.0/.github/workflows/test.yml000066400000000000000000000012211366725761600207110ustar00rootroot00000000000000name: Run Tests on: [push] jobs: test: name: Test on python ${{ matrix.python-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: python-version: [3.6, 3.7, 3.8] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Install tox run: | python -m pip install --upgrade pip pip install tox - name: Run tox run: | tox --skip-missing-interpreters true git-revise-0.6.0/.gitignore000066400000000000000000000024571366725761600156160ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ git-revise-0.6.0/.travis.yml000066400000000000000000000003271366725761600157310ustar00rootroot00000000000000sudo: false language: python matrix: include: - python: "3.6" - python: "3.7" - python: "3.8" sudo: true dist: "xenial" install: - travis_retry pip install tox-travis script: - tox git-revise-0.6.0/CHANGELOG.md000066400000000000000000000023231366725761600154270ustar00rootroot00000000000000# Changelog ## v0.6.0 * Fixed handling of fixup-of-fixup commits (#58) * Added support for `git add`'s `--patch` flag (#61) * Manpage is now installed in `share/man/man1` instead of `man/man1` (#62) * Which patch failed to apply is now included in the conflict editor (#53) * Trailing whitespaces are no longer generated for empty comment lines (#50) * Use `sequence.editor` when editing `revise-todo` (#60) ## v0.5.1 * Support non-ASCII branchnames. (#48) * LICENSE included in PyPi package. (#44) ## v0.5.0 * Invoke `GIT_EDITOR` correctly when it includes quotes. * Use `sh` instead of `bash` to run `GIT_EDITOR`. * Added support for the `core.commentChar` config option. * Added the `revise.autoSquash` config option to imply `--autosquash` by default. * Added support for unambiguous abbreviated refs. ## v0.4.2 * Fixes a bug where the tempdir path is set incorrectly when run from a subdirectory. ## v0.4.1 * Improved the performance and UX for the `cut` command. ## v0.4.0 * Support for combining `--interactive` and `--edit` commands to perform bulk commit message editing during interactive mode. * No longer eagerly parses author/committer signatures, avoiding crashes when encountering broken signatures. git-revise-0.6.0/LICENSE000066400000000000000000000020401366725761600146170ustar00rootroot00000000000000Copyright (c) 2018 Nika Layzell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. git-revise-0.6.0/MANIFEST.in000066400000000000000000000000201366725761600153440ustar00rootroot00000000000000include LICENSE git-revise-0.6.0/README.md000066400000000000000000000026161366725761600151020ustar00rootroot00000000000000# git revise [![Build Status](https://travis-ci.org/mystor/git-revise.svg?branch=master)](https://travis-ci.org/mystor/git-revise) [![PyPi](https://img.shields.io/pypi/v/git-revise.svg)](https://pypi.org/project/git-revise) [![Documentation Status](https://readthedocs.org/projects/git-revise/badge/?version=latest)](https://git-revise.readthedocs.io/en/latest/?badge=latest) `git revise` is a `git` subcommand to efficiently update, split, and rearrange commits. It is heavily inspired by `git rebase`, however it tries to be more efficient and ergonomic for patch-stack oriented workflows. By default, `git revise` will apply staged changes to a target commit, then update `HEAD` to point at the revised history. It also supports splitting commits and rewording commit messages. Unlike `git rebase`, `git revise` avoids modifying the working directory or the index state, performing all merges in-memory and only writing them when necessary. This allows it to be significantly faster on large codebases and avoids unnecessarily invalidating builds. ## Install ```sh $ pip install --user git-revise ``` Various people have also packaged `git revise` for platform-specific package managers (Thanks!) #### macOS Homebrew ```sh $ brew install git-revise ``` ## Documentation Documentation, including usage and examples, is hosted on [Read the Docs]. [Read the Docs]: https://git-revise.readthedocs.io/en/latest git-revise-0.6.0/docs/000077500000000000000000000000001366725761600145465ustar00rootroot00000000000000git-revise-0.6.0/docs/Makefile000066400000000000000000000014551366725761600162130ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = git-revise SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help man Makefile # Copy generated manfile into project root for distribution. man: Makefile @$(SPHINXBUILD) -M man "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) cp "$(BUILDDIR)/man/git-revise.1" "$(SOURCEDIR)/.." # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) git-revise-0.6.0/docs/api/000077500000000000000000000000001366725761600153175ustar00rootroot00000000000000git-revise-0.6.0/docs/api/index.rst000066400000000000000000000003671366725761600171660ustar00rootroot00000000000000======================== The ``gitrevise`` module ======================== Python modules for interacting with git objects used by :ref:`git_revise`. .. toctree:: :maxdepth: 2 :caption: Submodules: merge odb todo tui utils git-revise-0.6.0/docs/api/merge.rst000066400000000000000000000002321366725761600171450ustar00rootroot00000000000000----------------------------------- ``merge`` -- Quick in-memory merges ----------------------------------- .. automodule:: gitrevise.merge :members: git-revise-0.6.0/docs/api/odb.rst000066400000000000000000000002551366725761600166170ustar00rootroot00000000000000------------------------------------------ ``odb`` -- Git object database interaction ------------------------------------------ .. automodule:: gitrevise.odb :members: git-revise-0.6.0/docs/api/todo.rst000066400000000000000000000002261366725761600170160ustar00rootroot00000000000000---------------------------------- ``todo`` -- History edit sequences ---------------------------------- .. automodule:: gitrevise.todo :members: git-revise-0.6.0/docs/api/tui.rst000066400000000000000000000002361366725761600166530ustar00rootroot00000000000000------------------------------------- ``tui`` -- ``git-revise`` entry point ------------------------------------- .. automodule:: gitrevise.tui :members: git-revise-0.6.0/docs/api/utils.rst000066400000000000000000000002241366725761600172070ustar00rootroot00000000000000--------------------------------- ``utils`` -- Misc. helper methods --------------------------------- .. automodule:: gitrevise.utils :members: git-revise-0.6.0/docs/conf.py000066400000000000000000000076621366725761600160600ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. import os import sys sys.path.insert(0, os.path.abspath("..")) import gitrevise # -- Project information ----------------------------------------------------- project = "git-revise" copyright = "2018-2019, Nika Layzell" author = "Nika Layzell " # The short X.Y version version = gitrevise.__version__ # The full version, including alpha/beta/rc tags release = version # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ["sphinx.ext.autodoc"] # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} manpages_url = "https://manpages.debian.org/{path}" # Manpages links fail to be handled correctly in the version of sphinx used by # Read the Docs -- disable the HTML5 writer. html_experimental_html5_writer = False # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( "man", "git-revise", "Efficiently update, split, and rearrange git commits", None, 1, ) ] # -- Extension configuration ------------------------------------------------- def setup(app): app.add_object_type("gitconfig", "gitconfig", objname="git config value") git-revise-0.6.0/docs/contributing.rst000066400000000000000000000014501366725761600200070ustar00rootroot00000000000000Contributing ============ Running Tests ------------- :command:`tox` is used to run tests. It will run :command:`mypy` for type checking, :command:`pylint` for linting, :command:`pytest` for testing, and :command:`black` for code formatting. .. code-block:: shell $ tox # All python versions $ tox -e py36 # Python 3.6 $ tox -e py37 # Python 3.7 Code Formatting --------------- This project uses ``black`` for code formatting. .. code-block:: shell $ black . # format all python code Building Documentation ---------------------- Documentation is built using :command:`sphinx`. .. code-block:: shell $ cd docs/ $ make man # Build manpage Publishing ---------- .. code-block:: shell $ python3 setup.py sdist bdist_wheel $ twine check dist/* $ twine upload dist/* git-revise-0.6.0/docs/index.rst000066400000000000000000000006351366725761600164130ustar00rootroot00000000000000========== git-revise ========== ``git-revise`` is a :manpage:`git(1)` subcommand, and :manpage:`python(1)` library for efficiently updating, splitting, and rearranging commits. :command:`git revise` is open-source, and can be found on GitHub_ .. toctree:: :maxdepth: 2 :caption: Contents: install man performance api/index contributing .. _GitHub: https://github.com/mystor/git-revise git-revise-0.6.0/docs/install.rst000066400000000000000000000003251366725761600167460ustar00rootroot00000000000000Installing ========== :command:`git-revise` can be installed from PyPi_. Python 3.6 or higher is required. .. code-block:: bash $ pip install --user git-revise .. _PyPi: https://pypi.org/project/git-revise/ git-revise-0.6.0/docs/man.rst000066400000000000000000000174221366725761600160610ustar00rootroot00000000000000.. _git_revise: ========================================================================= ``git-revise(1)`` -- Efficiently update, split, and rearrange git commits ========================================================================= .. program:: git revise: SYNOPSIS ======== *git revise* [] [] DESCRIPTION =========== :program:`git revise` is a :manpage:`git(1)` subcommand to efficiently update, split, and rearrange commits. It is heavily inspired by :manpage:`git-rebase(1)`, however tries to be more efficient and ergonomic for patch-stack oriented workflows. By default, :program:`git revise` will apply staged changes to , updating ``HEAD`` to point at the revised history. It also supports splitting commits, rewording commit messages. Unlike :manpage:`git-rebase(1)`, :program:`git revise` avoids modifying working directory and index state, performing all merges in-memory, and only writing them when necessary. This allows it to be significantly faster on large codebases, and avoid invalidating builds. If :option:`--autosquash` or :option:`--interactive` is specified, the argument is optional. If it is omitted, :program:`git revise` will consider a range of unpublished commits on the current branch. OPTIONS ======= General options --------------- .. option:: -a, --all Stage changes to tracked files before revising. .. option:: -p, --patch Interactively stage hunks from the worktree before revising. .. option:: --no-index Ignore staged changes in the index. .. option:: --reauthor Reset target commit's author to the current user. .. option:: --ref Working branch to update; defaults to ``HEAD``. Main modes of operation ----------------------- .. option:: -i, --interactive Rather than applying staged changes to , edit a todo list of actions to perform on commits after . See :ref:`interactive-mode`. .. option:: --autosquash, --no-autosquash Rather than directly applying staged changes to , automatically perform fixup or squash actions marked with ``fixup!`` or ``squash!`` between and the current ``HEAD``. For more information on what these actions do, see :ref:`interactive-mode`. These commits are usually created with ``git commit --fixup=`` or ``git commit --squash=``, and identify the target with the first line of its commit message. This option can be combined with :option:`--interactive` to modify the generated todos before they're executed. If the :option:`--autosquash` option is enabled by default using a configuration variable, the option :option:`--no-autosquash` can be used to override and disable this setting. See :ref:`configuration`. .. option:: -c, --cut Interactively select hunks from . The chosen hunks are split into a second commit immediately after the target. After splitting is complete, both commits' messages are edited. See the "Interactive Mode" section of :manpage:`git-add(1)` to learn how to operate this mode. .. option:: -e, --edit After applying staged changes, edit 's commit message. This option can be combined with :option:`--interactive` to allow editing of commit messages within the todo list. For more information on, see :ref:`interactive-mode`. .. option:: -m , --message Use the given as the new commit message for . If multiple :option:`-m` options are given, their values are concatenated as separate paragraphs. .. option:: --version Print version information and exit. .. _configuration: CONFIGURATION ============= Configuration is managed by :manpage:`git-config(1)`. .. gitconfig:: revise.autoSquash If set to true, imply :option:`--autosquash` whenever :option:`--interactive` is specified. Overridden by :option:`--no-autosquash`. Defaults to false. If not set, the value of ``rebase.autoSquash`` is used instead. CONFLICT RESOLUTION =================== When a conflict is encountered, :command:`git revise` will attempt to resolve it automatically using standard git mechanisms. If automatic resolution fails, the user will be prompted to resolve them manually. There is currently no support for using :manpage:`git-mergetool(1)` to resolve conflicts. No attempt is made to detect renames of files or directories. :command:`git revise` may produce suboptimal results across renames. Use the interactive mode of :manpage:`git-rebase(1)` when rename tracking is important. NOTES ===== A successful :command:`git revise` will add a single entry to the reflog, allowing it to be undone with ``git reset @{1}``. Unsuccessful :command:`git revise` commands will leave your repository largely unmodified. No merge commits may occur between the target commit and ``HEAD``, as rewriting them is not supported. See :manpage:`git-rebase(1)` for more information on the implications of modifying history on a repository that you share. .. _interactive-mode: INTERACTIVE MODE ================ :command:`git revise` supports an interactive mode inspired by the interactive mode of :manpage:`git-rebase(1)`. This mode is started with the last commit you want to retain "as-is": .. code-block:: bash git revise -i An editor will be fired up with the commits in your current branch after the given commit. If the index has any staged but uncommitted changes, a ```` entry will also be present. .. code-block:: none pick 8338dfa88912 Oneline summary of first commit pick 735609912343 Summary of second commit index 672841329981 These commits may be re-ordered to change the order they appear in history. In addition, the ``pick`` and ``index`` commands may be replaced to modify their behaviour. If present, ``index`` commands must be at the bottom of the list, i.e. they can not be followed by non-index commands. If :option:`-e` was specified, the full commit message will be included, and each command line will begin with a ``++``. Any changes made to the commit messages in this file will be applied to the commit in question, allowing for simultaneous editing of commit messages during the todo editing phase. .. code-block:: none ++ pick 8338dfa88912 Oneline summary of first commit Body of first commit ++ pick 735609912343 Summary of second commit Body of second commit ++ index 672841329981 The following commands are supported in all interactive modes: .. describe:: index Do not commit these changes, instead leaving them staged in the index. Index lines must come last in the file. .. note: Commits may not be deleted or dropped from the to-do list. To remove a commit, mark it as an index action, and use :manpage:`git-reset(1)` to discard staged changes. .. describe:: pick Use the given commit as-is in history. When applied to the generated ``index`` entry, the commit will have the message ````. .. describe:: squash Add the commit's changes into the previous commit and open an editor to merge the commits' messages. .. describe:: fixup Like squash, but discard this commit's message rather than editing. .. describe:: reword Open an editor to modify the commit message. .. describe:: cut Interactively select hunks from the commit. The chosen hunks are split into a second commit immediately after it. After splitting is complete, both commits' messages are edited. See the "Interactive Mode" section of :manpage:`git-add(1)` to learn how to operate this mode. REPORTING BUGS ============== Please report issues and feature requests to the issue tracker at https://github.com/mystor/git-revise/issues. Code, documentation and other contributions are also welcomed. SEE ALSO ======== :manpage:`git(1)` :manpage:`git-rebase(1)` :manpage:`git-add(1)` git-revise-0.6.0/docs/performance.rst000066400000000000000000000044461366725761600176110ustar00rootroot00000000000000Performance =========== .. note:: These numbers are from an earlier version, and may not reflect the current state of `git-revise`. With large repositories such as ``mozilla-central``, :command:`git revise` is often significantly faster than :manpage:`git-rebase(1)` for incremental, due to not needing to update the index or working directory during rebases. I did a simple test, applying a single-line change to a commit 11 patches up the stack. The following are my extremely non-scientific time measurements: ============================== ========= Command Real Time ============================== ========= ``git rebase -i --autosquash`` 16.931s ``git revise`` 0.541s ============================== ========= The following are the commands I ran: .. code-block:: bash # Apply changes with git rebase -i --autosquash $ git reset 6fceb7da316d && git add . $ time bash -c 'TARGET=14f1c85bf60d; git commit --fixup=$TARGET; EDITOR=true git rebase -i --autosquash $TARGET~' real 0m16.931s user 0m15.289s sys 0m3.579s # Apply changes with git revise $ git reset 6fceb7da316d && git add . $ time git revise 14f1c85bf60d real 0m0.541s user 0m0.354s sys 0m0.150s How is it faster? ----------------- .. rubric:: In-Memory Cache To avoid spawning unnecessary subprocesses and hitting disk too frequently, :command:`git revise` uses an in-memory cache of objects in the ODB which it has already seen. Intermediate git trees, blobs, and commits created during processing are helds exclusively in-memory, and only persisted when necessary. .. rubric:: Custom Merge Algorithm A custom implementation of the merge algorithm is used which directly merges trees rather than using the index. This ends up being faster on large repositories, as only the subset of modified files and directories need to be examined when merging. .. note:: Currently this algorithm is incapable of handling copy and rename operations correctly, instead treating them as file creation and deletion actions. This may be resolveable in the future. .. rubric:: Avoiding Index & Working Directory The working directory and index are never examined or updated during the rebasing process, avoiding disk I/O and invalidating existing builds. git-revise-0.6.0/git-revise.1000066400000000000000000000205501366725761600157600ustar00rootroot00000000000000.\" Man page generated from reStructuredText. . .TH "GIT-REVISE" "1" "Jun 07, 2020" "0.6.0" "git-revise" .SH NAME git-revise \- Efficiently update, split, and rearrange git commits . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .SH SYNOPSIS .sp \fIgit revise\fP [] [] .SH DESCRIPTION .sp \fBgit revise\fP is a \fBgit(1)\fP subcommand to efficiently update, split, and rearrange commits. It is heavily inspired by \fBgit\-rebase(1)\fP, however tries to be more efficient and ergonomic for patch\-stack oriented workflows. .sp By default, \fBgit revise\fP will apply staged changes to , updating \fBHEAD\fP to point at the revised history. It also supports splitting commits, rewording commit messages. .sp Unlike \fBgit\-rebase(1)\fP, \fBgit revise\fP avoids modifying working directory and index state, performing all merges in\-memory, and only writing them when necessary. This allows it to be significantly faster on large codebases, and avoid invalidating builds. .sp If \fI\%\-\-autosquash\fP or \fI\%\-\-interactive\fP is specified, the argument is optional. If it is omitted, \fBgit revise\fP will consider a range of unpublished commits on the current branch. .SH OPTIONS .SS General options .INDENT 0.0 .TP .B \-a, \-\-all Stage changes to tracked files before revising. .UNINDENT .INDENT 0.0 .TP .B \-p, \-\-patch Interactively stage hunks from the worktree before revising. .UNINDENT .INDENT 0.0 .TP .B \-\-no\-index Ignore staged changes in the index. .UNINDENT .INDENT 0.0 .TP .B \-\-reauthor Reset target commit\(aqs author to the current user. .UNINDENT .INDENT 0.0 .TP .B \-\-ref Working branch to update; defaults to \fBHEAD\fP\&. .UNINDENT .SS Main modes of operation .INDENT 0.0 .TP .B \-i, \-\-interactive Rather than applying staged changes to , edit a todo list of actions to perform on commits after . See \fI\%INTERACTIVE MODE\fP\&. .UNINDENT .INDENT 0.0 .TP .B \-\-autosquash, \-\-no\-autosquash Rather than directly applying staged changes to , automatically perform fixup or squash actions marked with \fBfixup!\fP or \fBsquash!\fP between and the current \fBHEAD\fP\&. For more information on what these actions do, see \fI\%INTERACTIVE MODE\fP\&. .sp These commits are usually created with \fBgit commit \-\-fixup=\fP or \fBgit commit \-\-squash=\fP, and identify the target with the first line of its commit message. .sp This option can be combined with \fI\%\-\-interactive\fP to modify the generated todos before they\(aqre executed. .sp If the \fI\%\-\-autosquash\fP option is enabled by default using a configuration variable, the option \fI\%\-\-no\-autosquash\fP can be used to override and disable this setting. See \fI\%CONFIGURATION\fP\&. .UNINDENT .INDENT 0.0 .TP .B \-c, \-\-cut Interactively select hunks from . The chosen hunks are split into a second commit immediately after the target. .sp After splitting is complete, both commits\(aq messages are edited. .sp See the "Interactive Mode" section of \fBgit\-add(1)\fP to learn how to operate this mode. .UNINDENT .INDENT 0.0 .TP .B \-e, \-\-edit After applying staged changes, edit \(aqs commit message. .sp This option can be combined with \fI\%\-\-interactive\fP to allow editing of commit messages within the todo list. For more information on, see \fI\%INTERACTIVE MODE\fP\&. .UNINDENT .INDENT 0.0 .TP .B \-m , \-\-message Use the given as the new commit message for . If multiple \fI\%\-m\fP options are given, their values are concatenated as separate paragraphs. .UNINDENT .INDENT 0.0 .TP .B \-\-version Print version information and exit. .UNINDENT .SH CONFIGURATION .sp Configuration is managed by \fBgit\-config(1)\fP\&. .INDENT 0.0 .TP .B revise.autoSquash If set to true, imply \fI\%\-\-autosquash\fP whenever \fI\%\-\-interactive\fP is specified. Overridden by \fI\%\-\-no\-autosquash\fP\&. Defaults to false. If not set, the value of \fBrebase.autoSquash\fP is used instead. .UNINDENT .SH CONFLICT RESOLUTION .sp When a conflict is encountered, \fBgit revise\fP will attempt to resolve it automatically using standard git mechanisms. If automatic resolution fails, the user will be prompted to resolve them manually. .sp There is currently no support for using \fBgit\-mergetool(1)\fP to resolve conflicts. .sp No attempt is made to detect renames of files or directories. \fBgit revise\fP may produce suboptimal results across renames. Use the interactive mode of \fBgit\-rebase(1)\fP when rename tracking is important. .SH NOTES .sp A successful \fBgit revise\fP will add a single entry to the reflog, allowing it to be undone with \fBgit reset @{1}\fP\&. Unsuccessful \fBgit revise\fP commands will leave your repository largely unmodified. .sp No merge commits may occur between the target commit and \fBHEAD\fP, as rewriting them is not supported. .sp See \fBgit\-rebase(1)\fP for more information on the implications of modifying history on a repository that you share. .SH INTERACTIVE MODE .sp \fBgit revise\fP supports an interactive mode inspired by the interactive mode of \fBgit\-rebase(1)\fP\&. .sp This mode is started with the last commit you want to retain "as\-is": .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C git revise \-i .ft P .fi .UNINDENT .UNINDENT .sp An editor will be fired up with the commits in your current branch after the given commit. If the index has any staged but uncommitted changes, a \fB\fP entry will also be present. .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C pick 8338dfa88912 Oneline summary of first commit pick 735609912343 Summary of second commit index 672841329981 .ft P .fi .UNINDENT .UNINDENT .sp These commits may be re\-ordered to change the order they appear in history. In addition, the \fBpick\fP and \fBindex\fP commands may be replaced to modify their behaviour. If present, \fBindex\fP commands must be at the bottom of the list, i.e. they can not be followed by non\-index commands. .sp If \fI\%\-e\fP was specified, the full commit message will be included, and each command line will begin with a \fB++\fP\&. Any changes made to the commit messages in this file will be applied to the commit in question, allowing for simultaneous editing of commit messages during the todo editing phase. .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C ++ pick 8338dfa88912 Oneline summary of first commit Body of first commit ++ pick 735609912343 Summary of second commit Body of second commit ++ index 672841329981 .ft P .fi .UNINDENT .UNINDENT .sp The following commands are supported in all interactive modes: .INDENT 0.0 .TP .B index Do not commit these changes, instead leaving them staged in the index. Index lines must come last in the file. .UNINDENT .INDENT 0.0 .TP .B pick Use the given commit as\-is in history. When applied to the generated \fBindex\fP entry, the commit will have the message \fB\fP\&. .UNINDENT .INDENT 0.0 .TP .B squash Add the commit\(aqs changes into the previous commit and open an editor to merge the commits\(aq messages. .UNINDENT .INDENT 0.0 .TP .B fixup Like squash, but discard this commit\(aqs message rather than editing. .UNINDENT .INDENT 0.0 .TP .B reword Open an editor to modify the commit message. .UNINDENT .INDENT 0.0 .TP .B cut Interactively select hunks from the commit. The chosen hunks are split into a second commit immediately after it. .sp After splitting is complete, both commits\(aq messages are edited. .sp See the "Interactive Mode" section of \fBgit\-add(1)\fP to learn how to operate this mode. .UNINDENT .SH REPORTING BUGS .sp Please report issues and feature requests to the issue tracker at \fI\%https://github.com/mystor/git\-revise/issues\fP\&. .sp Code, documentation and other contributions are also welcomed. .SH SEE ALSO .sp \fBgit(1)\fP \fBgit\-rebase(1)\fP \fBgit\-add(1)\fP .SH COPYRIGHT 2018-2019, Nika Layzell .\" Generated by docutils manpage writer. . git-revise-0.6.0/gitrevise/000077500000000000000000000000001366725761600156175ustar00rootroot00000000000000git-revise-0.6.0/gitrevise/__init__.py000066400000000000000000000003271366725761600177320ustar00rootroot00000000000000""" gitrevise is a library for efficiently working with changes in git repositories. It holds an in-memory copy of the object database and supports efficient in-memory merges and rebases. """ __version__ = "0.6.0" git-revise-0.6.0/gitrevise/__main__.py000066400000000000000000000000751366725761600177130ustar00rootroot00000000000000from .tui import main if __name__ == "__main__": main() git-revise-0.6.0/gitrevise/merge.py000066400000000000000000000163231366725761600172750ustar00rootroot00000000000000""" This module contains a basic implementation of an efficient, in-memory 3-way git tree merge. This is used rather than traditional git mechanisms to avoid needing to use the index file format, which can be slow to initialize for large repositories. The INDEX file for my local mozilla-central checkout, for reference, is 35MB. While this isn't huge, it takes a perceptable amount of time to read the tree files and generate. This algorithm, on the other hand, avoids looking at unmodified trees and blobs when possible. """ from typing import Optional, Tuple, TypeVar from pathlib import Path from subprocess import CalledProcessError from .odb import Tree, Blob, Commit, Entry, Mode from .utils import edit_file T = TypeVar("T") # pylint: disable=C0103 class MergeConflict(Exception): pass def rebase(commit: Commit, parent: Commit) -> Commit: if commit.parent() == parent: return commit # No need to do anything tree = merge_trees( Path("/"), (parent.summary(), commit.parent().summary(), commit.summary()), parent.tree(), commit.parent().tree(), commit.tree(), ) # NOTE: This omits commit.committer to pull it from the environment. This # means that created commits may vary between invocations, but due to # caching, should be consistent within a single process. return tree.repo.new_commit(tree, [parent], commit.message, commit.author) def conflict_prompt( path: Path, descr: str, labels: Tuple[str, str, str], current: T, current_descr: str, other: T, other_descr: str, ) -> T: print(f"{descr} conflict for '{path}'") print(f" (1) {labels[0]}: {current_descr}") print(f" (2) {labels[1]}: {other_descr}") char = input("Resolution or (A)bort? ") if char == "1": return current if char == "2": return other raise MergeConflict("aborted") def merge_trees( path: Path, labels: Tuple[str, str, str], current: Tree, base: Tree, other: Tree ) -> Tree: # Merge every named entry which is mentioned in any tree. names = set(current.entries.keys()).union(base.entries.keys(), other.entries.keys()) entries = {} for name in names: merged = merge_entries( path / name.decode(errors="replace"), labels, current.entries.get(name), base.entries.get(name), other.entries.get(name), ) if merged is not None: entries[name] = merged return current.repo.new_tree(entries) def merge_entries( path: Path, labels: Tuple[str, str, str], current: Optional[Entry], base: Optional[Entry], other: Optional[Entry], ) -> Optional[Entry]: if base == current: return other # no change from base -> current if base == other: return current # no change from base -> other if current == other: return current # base -> current & base -> other are identical # If one of the branches deleted the entry, and the other modified it, # report a merge conflict. if current is None: return conflict_prompt( path, "Deletion", labels, current, "deleted", other, "modified" ) if other is None: return conflict_prompt( path, "Deletion", labels, current, "modified", other, "deleted" ) # Determine which mode we're working with here. if current.mode == other.mode: mode = current.mode # current & other agree elif current.mode.is_file() and other.mode.is_file(): # File types support both Mode.EXEC and Mode.REGULAR, try to pick one. if base and base.mode == current.mode: mode = other.mode elif base and base.mode == other.mode: mode = current.mode else: mode = Mode.EXEC # XXX: gross else: return conflict_prompt( path, "Entry type", labels, current, str(current.mode), other, str(other.mode), ) # Time to merge the actual entries! if mode.is_file(): baseblob = None if base and base.mode.is_file(): baseblob = base.blob() return Entry( current.repo, mode, merge_blobs(path, labels, current.blob(), baseblob, other.blob()).oid, ) if mode == Mode.DIR: basetree = current.repo.new_tree({}) if base and base.mode == Mode.DIR: basetree = base.tree() return Entry( current.repo, mode, merge_trees(path, labels, current.tree(), basetree, other.tree()).oid, ) if mode == Mode.SYMLINK: return conflict_prompt( path, "Symlink", labels, current, current.symlink().decode(), other, other.symlink().decode(), ) if mode == Mode.GITLINK: return conflict_prompt( path, "Submodule", labels, current, str(current.oid), other, str(other.oid) ) raise ValueError("unknown mode") def merge_blobs( path: Path, labels: Tuple[str, str, str], current: Blob, base: Optional[Blob], other: Blob, ) -> Blob: repo = current.repo tmpdir = repo.get_tempdir() (tmpdir / "current").write_bytes(current.body) (tmpdir / "base").write_bytes(base.body if base else b"") (tmpdir / "other").write_bytes(other.body) # Try running git merge-file to automatically resolve conflicts. try: merged = repo.git( "merge-file", "-q", "-p", f"-L{path} (new parent): {labels[0]}", f"-L{path} (old parent): {labels[1]}", f"-L{path} (current): {labels[2]}", str(tmpdir / "current"), str(tmpdir / "base"), str(tmpdir / "other"), newline=False, ) except CalledProcessError as err: # The return code is the # of conflicts if there are conflicts, and # negative if there is an error. if err.returncode < 0: raise # At this point, we know that there are merge conflicts to resolve. # Prompt to try and trigger manual resolution. print(f"Conflict applying '{labels[2]}'") print(f" Path: '{path}'") if input(" Edit conflicted file? (Y/n) ").lower() == "n": raise MergeConflict("user aborted") # Open the editor on the conflicted file. We ensure the relative path # matches the path of the original file for a better editor experience. conflicts = tmpdir / "conflict" / path.relative_to("/") conflicts.parent.mkdir(parents=True, exist_ok=True) conflicts.write_bytes(err.output) merged = edit_file(repo, conflicts) # Print warnings if the merge looks like it may have failed. if merged == err.output: print("(note) conflicted file is unchanged") if b"<<<<<<<" in merged or b"=======" in merged or b">>>>>>>" in merged: print("(note) conflict markers found in the merged file") # Was the merge successful? if input(" Merge successful? (y/N) ").lower() != "y": raise MergeConflict("user aborted") return Blob(current.repo, merged) git-revise-0.6.0/gitrevise/odb.py000066400000000000000000000601151366725761600167400ustar00rootroot00000000000000""" Helper classes for reading cached objects from Git's Object Database. """ import hashlib import re import os from typing import ( TypeVar, Type, Dict, Union, Sequence, Optional, Mapping, Generic, Tuple, cast, ) from types import TracebackType from pathlib import Path from enum import Enum from subprocess import Popen, run, PIPE, CalledProcessError from collections import defaultdict from tempfile import TemporaryDirectory class MissingObject(Exception): """Exception raised when a commit cannot be found in the ODB""" def __init__(self, ref: str): Exception.__init__(self, f"Object {ref} does not exist") T = TypeVar("T") # pylint: disable=invalid-name class Oid(bytes): """Git object identifier""" __slots__ = () def __new__(cls, b: bytes) -> "Oid": if len(b) != 20: raise ValueError("Expected 160-bit SHA1 hash") return super().__new__(cls, b) # type: ignore @classmethod def fromhex(cls, instr: str) -> "Oid": """Parse an ``Oid`` from a hexadecimal string""" return Oid(bytes.fromhex(instr)) @classmethod def null(cls) -> "Oid": """An ``Oid`` consisting of entirely 0s""" return cls(b"\0" * 20) def short(self) -> str: """A shortened version of the Oid's hexadecimal form""" return str(self)[:12] @classmethod def for_object(cls, tag: str, body: bytes): """Hash an object with the given type tag and body to determine its Oid""" hasher = hashlib.sha1() hasher.update(tag.encode() + b" " + str(len(body)).encode() + b"\0" + body) return cls(hasher.digest()) def __repr__(self) -> str: return self.hex() def __str__(self) -> str: return self.hex() class Signature(bytes): """Git user signature""" __slots__ = () sig_re = re.compile( rb""" (?P[^<>]+)<(?P[^<>]+)>[ ] (?P[0-9]+) (?:[ ](?P[\+\-][0-9]+))? """, re.X, ) @property def name(self) -> bytes: """user name""" match = self.sig_re.fullmatch(self) assert match, "invalid signature" return match.group("name").strip() @property def email(self) -> bytes: """user email""" match = self.sig_re.fullmatch(self) assert match, "invalid signature" return match.group("email").strip() @property def timestamp(self) -> bytes: """unix timestamp""" match = self.sig_re.fullmatch(self) assert match, "invalid signature" return match.group("timestamp").strip() @property def offset(self) -> bytes: """timezone offset from UTC""" match = self.sig_re.fullmatch(self) assert match, "invalid signature" return match.group("offset").strip() class Repository: """Main entry point for a git repository""" workdir: Path """working directory for this repository""" gitdir: Path """.git directory for this repository""" default_author: Signature """author used by default for new commits""" default_committer: Signature """committer used by default for new commits""" index: "Index" """current index state""" _objects: Dict[int, Dict[Oid, "GitObj"]] _catfile: Popen _tempdir: Optional[TemporaryDirectory] __slots__ = [ "workdir", "gitdir", "default_author", "default_committer", "index", "_objects", "_catfile", "_tempdir", ] def __init__(self, cwd: Optional[Path] = None): self._tempdir = None self.workdir = Path(self.git("rev-parse", "--show-toplevel", cwd=cwd).decode()) self.gitdir = self.workdir / Path(self.git("rev-parse", "--git-dir").decode()) # XXX(nika): Does it make more sense to cache these or call every time? # Cache for length of time & invalidate? self.default_author = Signature(self.git("var", "GIT_AUTHOR_IDENT")) self.default_committer = Signature(self.git("var", "GIT_COMMITTER_IDENT")) self.index = Index(self) self._catfile = Popen( ["git", "cat-file", "--batch"], bufsize=-1, stdin=PIPE, stdout=PIPE, cwd=self.workdir, ) self._objects = defaultdict(dict) # Check that cat-file works OK try: self.get_obj(Oid.null()) raise IOError("cat-file backend failure") except MissingObject: pass def git( self, *cmd: str, cwd: Optional[Path] = None, stdin: Optional[bytes] = None, newline: bool = True, env: Dict[str, str] = None, nocapture: bool = False, ) -> bytes: if cwd is None: cwd = getattr(self, "workdir", None) cmd = ("git",) + cmd prog = run( cmd, stdout=None if nocapture else PIPE, cwd=cwd, env=env, input=stdin, check=True, ) if nocapture: return b"" if newline and prog.stdout.endswith(b"\n"): return prog.stdout[:-1] return prog.stdout def config(self, setting: str, default: T) -> Union[bytes, T]: try: return self.git("config", "--get", setting) except CalledProcessError: return default def bool_config(self, config: str, default: T) -> Union[bool, T]: try: return self.git("config", "--get", "--bool", config) == b"true" except CalledProcessError: return default def int_config(self, config: str, default: T) -> Union[int, T]: try: return int(self.git("config", "--get", "--int", config)) except CalledProcessError: return default def __enter__(self) -> "Repository": return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[Exception], exc_tb: Optional[TracebackType], ): if self._tempdir: self._tempdir.__exit__(exc_type, exc_val, exc_tb) self._catfile.terminate() self._catfile.wait() def get_tempdir(self) -> Path: """Return a temporary directory to use for modifications to this repository""" if self._tempdir is None: self._tempdir = TemporaryDirectory(prefix="revise.", dir=str(self.gitdir)) return Path(self._tempdir.name) def git_path(self, path: Union[str, Path]) -> Path: """Get the path to a file in the .git directory, respecting the environment""" return self.workdir / self.git("rev-parse", "--git-path", str(path)).decode() def new_commit( self, tree: "Tree", parents: Sequence["Commit"], message: bytes, author: Optional[Signature] = None, committer: Optional[Signature] = None, ) -> "Commit": """Directly create an in-memory commit object, without persisting it. If a commit object with these properties already exists, it will be returned instead.""" if author is None: author = self.default_author if committer is None: committer = self.default_committer body = b"tree " + tree.oid.hex().encode() + b"\n" for parent in parents: body += b"parent " + parent.oid.hex().encode() + b"\n" body += b"author " + author + b"\n" body += b"committer " + committer + b"\n" body += b"\n" body += message return Commit(self, body) def new_tree(self, entries: Mapping[bytes, "Entry"]) -> "Tree": """Directly create an in-memory tree object, without persisting it. If a tree object with these entries already exists, it will be returned instead.""" def entry_key(pair: Tuple[bytes, Entry]) -> bytes: name, entry = pair # Directories are sorted in the tree listing as though they have a # trailing slash in their name. if entry.mode == Mode.DIR: return name + b"/" return name body = b"" for name, entry in sorted(entries.items(), key=entry_key): body += cast(bytes, entry.mode.value) + b" " + name + b"\0" + entry.oid return Tree(self, body) def get_obj(self, ref: Union[Oid, str]) -> "GitObj": """Get the identified git object from this repository. If given an :class:`Oid`, the cache will be checked before asking git.""" if isinstance(ref, Oid): cache = self._objects[ref[0]] if ref in cache: return cache[ref] ref = ref.hex() # Satisfy mypy: otherwise these are Optional[IO[Any]]. (stdin, stdout) = (self._catfile.stdin, self._catfile.stdout) assert stdin is not None assert stdout is not None # Write out an object descriptor. stdin.write(ref.encode() + b"\n") stdin.flush() # Read in the response. resp = stdout.readline().decode() if resp.endswith("missing\n"): # If we have an abbreviated hash, check for in-memory commits. try: abbrev = bytes.fromhex(ref) for oid, obj in self._objects[abbrev[0]].items(): if oid.startswith(abbrev): return obj except (ValueError, IndexError): pass # Not an abbreviated hash, the entry is missing. raise MissingObject(ref) parts = resp.rsplit(maxsplit=2) oid, kind, size = Oid.fromhex(parts[0]), parts[1], int(parts[2]) body = stdout.read(size + 1)[:-1] assert size == len(body), "bad size?" # Create a corresponding git object. This will re-use the item in the # cache, if found, and add the item to the cache otherwise. if kind == "commit": obj = Commit(self, body) elif kind == "tree": obj = Tree(self, body) elif kind == "blob": obj = Blob(self, body) else: raise ValueError(f"Unknown object kind: {kind}") obj.persisted = True assert obj.oid == oid, "miscomputed oid" return obj def get_commit(self, ref: Union[Oid, str]) -> "Commit": """Like :py:meth:`get_obj`, but returns a :class:`Commit`""" obj = self.get_obj(ref) if isinstance(obj, Commit): return obj raise ValueError(f"{type(obj).__name__} {ref} is not a Commit!") def get_tree(self, ref: Union[Oid, str]) -> "Tree": """Like :py:meth:`get_obj`, but returns a :class:`Tree`""" obj = self.get_obj(ref) if isinstance(obj, Tree): return obj raise ValueError(f"{type(obj).__name__} {ref} is not a Tree!") def get_blob(self, ref: Union[Oid, str]) -> "Blob": """Like :py:meth:`get_obj`, but returns a :class:`Blob`""" obj = self.get_obj(ref) if isinstance(obj, Blob): return obj raise ValueError(f"{type(obj).__name__} {ref} is not a Blob!") def get_obj_ref(self, ref: str) -> "Reference[GitObj]": """Get a :class:`Reference` to a :class:`GitObj`""" return Reference(GitObj, self, ref) def get_commit_ref(self, ref: str) -> "Reference[Commit]": """Get a :class:`Reference` to a :class:`Commit`""" return Reference(Commit, self, ref) def get_tree_ref(self, ref: str) -> "Reference[Tree]": """Get a :class:`Reference` to a :class:`Tree`""" return Reference(Tree, self, ref) def get_blob_ref(self, ref: str) -> "Reference[Blob]": """Get a :class:`Reference` to a :class:`Blob`""" return Reference(Blob, self, ref) GitObjT = TypeVar("GitObjT", bound="GitObj") class GitObj: """In-memory representation of a git object. Instances of this object should be one of :class:`Commit`, :class:`Tree` or :class:`Blob`""" repo: Repository """:class:`Repository` object is associated with""" body: bytes """Raw body of object in bytes""" oid: Oid """:class:`Oid` of this git object""" persisted: bool """If ``True``, the object has been persisted to disk""" __slots__ = ("repo", "body", "oid", "persisted") def __new__(cls, repo: Repository, body: bytes): oid = Oid.for_object(cls._git_type(), body) cache = repo._objects[oid[0]] # pylint: disable=protected-access if oid in cache: return cache[oid] self = super().__new__(cls) self.repo = repo self.body = body self.oid = oid self.persisted = False cache[oid] = self self._parse_body() # pylint: disable=protected-access return self @classmethod def _git_type(cls) -> str: return cls.__name__.lower() def persist(self) -> Oid: """If this object has not been persisted to disk yet, persist it""" if self.persisted: return self.oid self._persist_deps() new_oid = self.repo.git( "hash-object", "--no-filters", "-t", self._git_type(), "-w", "--stdin", stdin=self.body, ) assert Oid.fromhex(new_oid.decode()) == self.oid self.persisted = True return self.oid def _persist_deps(self): pass def _parse_body(self): pass def __eq__(self, other: object) -> bool: if isinstance(other, GitObj): return self.oid == other.oid return False class Commit(GitObj): """In memory representation of a git ``commit`` object""" tree_oid: Oid """:class:`Oid` of this commit's ``tree`` object""" parent_oids: Sequence[Oid] """List of :class:`Oid` for this commit's parents""" author: Signature """:class:`Signature` of this commit's author""" committer: Signature """:class:`Signature` of this commit's committer""" message: bytes """Body of this commit's message""" __slots__ = ("tree_oid", "parent_oids", "author", "committer", "message") def _parse_body(self): # Split the header from the core commit message. hdrs, self.message = self.body.split(b"\n\n", maxsplit=1) # Parse the header to populate header metadata fields. self.parent_oids = [] for hdr in re.split(br"\n(?! )", hdrs): # Parse out the key-value pairs from the header, handling # continuation lines. key, value = hdr.split(maxsplit=1) value = value.replace(b"\n ", b"\n") if key == b"tree": self.tree_oid = Oid.fromhex(value.decode()) elif key == b"parent": self.parent_oids.append(Oid.fromhex(value.decode())) elif key == b"author": self.author = Signature(value) elif key == b"committer": self.committer = Signature(value) def tree(self) -> "Tree": """``tree`` object corresponding to this commit""" return self.repo.get_tree(self.tree_oid) def parents(self) -> Sequence["Commit"]: """List of parent commits""" return [self.repo.get_commit(parent) for parent in self.parent_oids] def parent(self) -> "Commit": """Helper method to get the single parent of a commit. Raises :class:`ValueError` if the incorrect number of parents are present.""" if len(self.parents()) != 1: raise ValueError(f"Commit {self.oid} has {len(self.parents())} parents") return self.parents()[0] def summary(self) -> str: """The summary line (first line) of the commit message""" return self.message.split(b"\n", maxsplit=1)[0].decode(errors="replace") def rebase(self, parent: "Commit") -> "Commit": """Create a new commit with the same changes, except with ``parent`` as it's parent.""" from .merge import rebase # pylint: disable=import-outside-toplevel return rebase(self, parent) def update( self, tree: Optional["Tree"] = None, parents: Optional[Sequence["Commit"]] = None, message: Optional[bytes] = None, author: Optional[Signature] = None, ) -> "Commit": """Create a new commit with specific properties updated or replaced""" # Compute parameters used to create the new object. if tree is None: tree = self.tree() if parents is None: parents = self.parents() if message is None: message = self.message if author is None: author = self.author # Check if the commit was unchanged to avoid creating a new commit if # only the committer has changed. unchanged = ( tree == self.tree() and parents == self.parents() and message == self.message and author == self.author ) if unchanged: return self return self.repo.new_commit(tree, parents, message, author) def _persist_deps(self) -> None: self.tree().persist() for parent in self.parents(): parent.persist() def __repr__(self) -> str: return ( f"" ) class Mode(Enum): """Mode for an entry in a ``tree``""" GITLINK = b"160000" """submodule entry""" SYMLINK = b"120000" """symlink entry""" DIR = b"40000" """directory entry""" REGULAR = b"100644" """regular entry""" EXEC = b"100755" """executable entry""" def is_file(self) -> bool: return self in (Mode.REGULAR, Mode.EXEC) def comparable_to(self, other: "Mode") -> bool: return self == other or (self.is_file() and other.is_file()) class Entry: """In memory representation of a single ``tree`` entry""" repo: Repository """:class:`Repository` this entry originates from""" mode: Mode """:class:`Mode` of the entry""" oid: Oid """:class:`Oid` of this entry's object""" __slots__ = ("repo", "mode", "oid") def __init__(self, repo: Repository, mode: Mode, oid: Oid): self.repo = repo self.mode = mode self.oid = oid def blob(self) -> "Blob": """Get the data for this entry as a :class:`Blob`""" if self.mode in (Mode.REGULAR, Mode.EXEC): return self.repo.get_blob(self.oid) return Blob(self.repo, b"") def symlink(self) -> bytes: """Get the data for this entry as a symlink""" if self.mode == Mode.SYMLINK: return self.repo.get_blob(self.oid).body return b"" def tree(self) -> "Tree": """Get the data for this entry as a :class:`Tree`""" if self.mode == Mode.DIR: return self.repo.get_tree(self.oid) return Tree(self.repo, b"") def persist(self) -> None: """:py:meth:`GitObj.persist` the git object referenced by this entry""" if self.mode != Mode.GITLINK: self.repo.get_obj(self.oid).persist() def __repr__(self): return f"" def __eq__(self, other: object) -> bool: if isinstance(other, Entry): return self.mode == other.mode and self.oid == other.oid return False class Tree(GitObj): """In memory representation of a git ``tree`` object""" entries: Dict[bytes, Entry] """mapping from entry names to entry objects in this tree""" __slots__ = ("entries",) def _parse_body(self): self.entries = {} rest = self.body while rest: mode, rest = rest.split(b" ", maxsplit=1) name, rest = rest.split(b"\0", maxsplit=1) entry_oid = Oid(rest[:20]) rest = rest[20:] self.entries[name] = Entry(self.repo, Mode(mode), entry_oid) def _persist_deps(self) -> None: for entry in self.entries.values(): entry.persist() def to_index(self, path: Path, skip_worktree: bool = False) -> "Index": """Read tree into a temporary index. If skip_workdir is ``True``, every entry in the index will have its "Skip Workdir" bit set.""" index = Index(self.repo, path) self.repo.git("read-tree", "--index-output=" + str(path), self.persist().hex()) # If skip_worktree is set, mark every file as --skip-worktree. if skip_worktree: # XXX(nika): Could be done with a pipe, which might improve perf. files = index.git("ls-files") index.git("update-index", "--skip-worktree", "--stdin", stdin=files) return index def __repr__(self) -> str: return f"" class Blob(GitObj): """In memory representation of a git ``blob`` object""" __slots__ = () def __repr__(self) -> str: return f"" class Index: """Handle on an index file""" repo: Repository """""" index_file: Path """Index file being referenced""" def __init__(self, repo: Repository, index_file: Optional[Path] = None): self.repo = repo if index_file is None: index_file = self.repo.git_path("index") self.index_file = index_file assert self.git("rev-parse", "--git-path", "index").decode() == str(index_file) def git( self, *cmd: str, cwd: Optional[Path] = None, stdin: Optional[bytes] = None, newline: bool = True, env: Optional[Mapping[str, str]] = None, nocapture: bool = False, ) -> bytes: """Invoke git with the given index as active""" env = dict(**env) if env is not None else dict(**os.environ) env["GIT_INDEX_FILE"] = str(self.index_file) return self.repo.git( *cmd, cwd=cwd, stdin=stdin, newline=newline, env=env, nocapture=nocapture ) def tree(self) -> Tree: """Get a :class:`Tree` object for this index's state""" oid = Oid.fromhex(self.git("write-tree").decode()) return self.repo.get_tree(oid) def commit( self, message: bytes = b"", parent: Optional[Commit] = None ) -> Commit: """Get a :class:`Commit` for this index's state. If ``parent`` is ``None``, use the current ``HEAD``""" if parent is None: parent = self.repo.get_commit("HEAD") return self.repo.new_commit(self.tree(), [parent], message) class Reference(Generic[GitObjT]): # pylint: disable=unsubscriptable-object """A git reference""" shortname: str """Short unresolved reference name, e.g. 'HEAD' or 'master'""" name: str """Resolved reference name, e.g. 'refs/tags/1.0.0' or 'refs/heads/master'""" target: Optional[GitObjT] """Referenced git object""" repo: Repository """Repository reference is attached to""" _type: Type[GitObjT] # FIXME: On python 3.6, pylint doesn't know what to do with __slots__ here. # __slots__ = ("name", "target", "repo", "_type") def __init__(self, obj_type: Type[GitObjT], repo: Repository, name: str): self._type = obj_type self.name = repo.git("rev-parse", "--symbolic-full-name", name).decode() self.repo = repo self.refresh() def refresh(self): """Re-read the target of this reference from disk""" try: obj = self.repo.get_obj(self.name) if not isinstance(obj, self._type): raise ValueError( f"{type(obj).__name__} {self.name} is not a {self._type.__name__}!" ) self.target = obj except MissingObject: self.target = None def update(self, new: GitObjT, reason: str): """Update this refreence to point to a new object. An entry with the reason ``reason`` will be added to the reflog.""" new.persist() args = ["update-ref", "-m", reason, self.name, str(new.oid)] if self.target is not None: args.append(str(self.target.oid)) self.repo.git(*args) self.target = new git-revise-0.6.0/gitrevise/todo.py000066400000000000000000000214071366725761600171420ustar00rootroot00000000000000import re from enum import Enum from typing import List, Optional from .odb import Commit, Repository from .utils import run_editor, run_sequence_editor, edit_commit_message, cut_commit class StepKind(Enum): PICK = "pick" FIXUP = "fixup" SQUASH = "squash" REWORD = "reword" CUT = "cut" INDEX = "index" def __str__(self) -> str: return str(self.value) @staticmethod def parse(instr: str) -> "StepKind": if "pick".startswith(instr): return StepKind.PICK if "fixup".startswith(instr): return StepKind.FIXUP if "squash".startswith(instr): return StepKind.SQUASH if "reword".startswith(instr): return StepKind.REWORD if "cut".startswith(instr): return StepKind.CUT if "index".startswith(instr): return StepKind.INDEX raise ValueError( f"step kind '{instr}' must be one of: pick, fixup, squash, reword, cut, or index" ) class Step: kind: StepKind commit: Commit message: Optional[bytes] def __init__(self, kind: StepKind, commit: Commit): self.kind = kind self.commit = commit self.message = None @staticmethod def parse(repo: Repository, instr: str) -> "Step": parsed = re.match(r"(?P\S+)\s(?P\S+)", instr) if not parsed: raise ValueError( f"todo entry '{instr}' must follow format " ) kind = StepKind.parse(parsed.group("command")) commit = repo.get_commit(parsed.group("hash")) return Step(kind, commit) def __str__(self) -> str: return f"{self.kind} {self.commit.oid.short()}" def __eq__(self, other: object) -> bool: if not isinstance(other, Step): return False return ( self.kind == other.kind and self.commit == other.commit and self.message == other.message ) def build_todos(commits: List[Commit], index: Optional[Commit]) -> List[Step]: steps = [Step(StepKind.PICK, commit) for commit in commits] if index: steps.append(Step(StepKind.INDEX, index)) return steps def validate_todos(old: List[Step], new: List[Step]): """Raise an exception if the new todo list is malformed compared to the original todo list""" old_set = set(o.commit.oid for o in old) new_set = set(n.commit.oid for n in new) assert len(old_set) == len(old), "Unexpected duplicate original commit!" if len(new_set) != len(new): # XXX(nika): Perhaps print which commits are duplicates? raise ValueError("Unexpected duplicate commit found in todos") if new_set - old_set: # XXX(nika): Perhaps print which commits were found? raise ValueError("Unexpected commits not referenced in original TODO list") if old_set - new_set: # XXX(nika): Perhaps print which commits were omitted? raise ValueError("Unexpected commits missing from TODO list") saw_index = False for step in new: if step.kind == StepKind.INDEX: saw_index = True elif saw_index: raise ValueError("'index' actions follow all non-index todo items") def autosquash_todos(todos: List[Step]) -> List[Step]: new_todos = todos[:] for step in todos: # Check if this is a fixup! or squash! commit, and ignore it otherwise. summary = step.commit.summary() if summary.startswith("fixup! "): kind = StepKind.FIXUP elif summary.startswith("squash! "): kind = StepKind.SQUASH else: continue # Locate a matching commit found = None needle = summary.split(maxsplit=1)[1] for idx, target in enumerate(new_todos): if target.commit.summary().startswith( needle ) or target.commit.oid.hex().startswith(needle): found = idx break if found is not None: # Insert a new `fixup` or `squash` step in the correct place. new_todos.insert(found + 1, Step(kind, step.commit)) # Remove the existing step. new_todos.remove(step) return new_todos def edit_todos_msgedit(repo: Repository, todos: List[Step]) -> List[Step]: todos_text = b"" for step in todos: todos_text += f"++ {step}\n".encode() todos_text += step.commit.message + b"\n" # Invoke the editors to parse commit messages. response = run_editor( repo, "git-revise-todo", todos_text, comments=f"""\ Interactive Revise Todos({len(todos)} commands) Commands: p, pick = use commit r, reword = use commit, but edit the commit message s, squash = use commit, but meld into previous commit f, fixup = like squash, but discard this commit's message c, cut = interactively split commit into two smaller commits i, index = leave commit changes staged, but uncommitted Each command block is prefixed by a '++' marker, followed by the command to run, the commit hash and after a newline the complete commit message until the next '++' marker or the end of the file. Commit messages will be reworded to match the provided message before the command is performed. These blocks are executed from top to bottom. They can be re-ordered and their commands can be changed, however the number of blocks must remain identical. If present, index blocks must be at the bottom of the list, i.e. they can not be followed by non-index blocks. If you remove everything, the revising process will be aborted. """, ) # Parse the response back into a list of steps result = [] for full in re.split(br"^\+\+ ", response, flags=re.M)[1:]: cmd, message = full.split(b"\n", maxsplit=1) step = Step.parse(repo, cmd.decode(errors="replace").strip()) step.message = message.strip() + b"\n" result.append(step) validate_todos(todos, result) return result def edit_todos(repo: Repository, todos: List[Step], msgedit=False) -> List[Step]: if msgedit: return edit_todos_msgedit(repo, todos) todos_text = b"" for step in todos: todos_text += f"{step} {step.commit.summary()}\n".encode() response = run_sequence_editor( repo, "git-revise-todo", todos_text, comments=f"""\ Interactive Revise Todos ({len(todos)} commands) Commands: p, pick = use commit r, reword = use commit, but edit the commit message s, squash = use commit, but meld into previous commit f, fixup = like squash, but discard this commit's log message c, cut = interactively split commit into two smaller commits i, index = leave commit changes staged, but uncommitted These lines are executed from top to bottom. They can be re-ordered and their commands can be changed, however the number of lines must remain identical. If present, index lines must be at the bottom of the list, i.e. they can not be followed by non-index lines. If you remove everything, the revising process will be aborted. """, ) # Parse the response back into a list of steps result = [] for line in response.splitlines(): if line.isspace(): continue step = Step.parse(repo, line.decode(errors="replace").strip()) result.append(step) validate_todos(todos, result) return result def apply_todos(current: Commit, todos: List[Step], reauthor: bool = False) -> Commit: for step in todos: rebased = step.commit.rebase(current).update(message=step.message) if step.kind == StepKind.PICK: current = rebased elif step.kind == StepKind.FIXUP: current = current.update(tree=rebased.tree()) elif step.kind == StepKind.REWORD: current = edit_commit_message(rebased) elif step.kind == StepKind.SQUASH: fused = current.message + b"\n\n" + rebased.message current = current.update(tree=rebased.tree(), message=fused) current = edit_commit_message(current) elif step.kind == StepKind.CUT: current = cut_commit(rebased) elif step.kind == StepKind.INDEX: break else: raise ValueError(f"Unknown StepKind value: {step.kind}") if reauthor: current = current.update(author=current.repo.default_author) print(f"{step.kind.value:6} {current.oid.short()} {current.summary()}") return current git-revise-0.6.0/gitrevise/tui.py000066400000000000000000000156031366725761600167770ustar00rootroot00000000000000from typing import Optional, List from argparse import ArgumentParser, Namespace from subprocess import CalledProcessError import sys from .odb import Repository, Commit, Reference from .utils import ( EditorError, commit_range, edit_commit_message, update_head, cut_commit, local_commits, ) from .todo import apply_todos, build_todos, edit_todos, autosquash_todos from .merge import MergeConflict from . import __version__ def build_parser() -> ArgumentParser: parser = ArgumentParser( description="""\ Rebase staged changes onto the given commit, and rewrite history to incorporate these changes.""" ) parser.add_argument("target", nargs="?", help="target commit to apply fixups to") parser.add_argument("--ref", default="HEAD", help="reference to update") parser.add_argument( "--reauthor", action="store_true", help="reset the author of the targeted commit", ) parser.add_argument("--version", action="version", version=__version__) parser.add_argument( "--edit", "-e", action="store_true", help="edit commit message of targeted commit(s)", ) autosquash_group = parser.add_mutually_exclusive_group() autosquash_group.add_argument( "--autosquash", action="store_true", help="automatically apply fixup! and squash! commits to their targets", ) autosquash_group.add_argument( "--no-autosquash", action="store_true", help="force disable revise.autoSquash behaviour", ) index_group = parser.add_mutually_exclusive_group() index_group.add_argument( "--no-index", action="store_true", help="ignore the index while rewriting history", ) index_group.add_argument( "--all", "-a", action="store_true", help="stage all tracked files before running", ) index_group.add_argument( "--patch", "-p", action="store_true", help="interactively stage hunks before running", ) mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument( "--interactive", "-i", action="store_true", help="interactively edit commit stack", ) mode_group.add_argument( "--message", "-m", action="append", help="specify commit message on command line", ) mode_group.add_argument( "--cut", "-c", action="store_true", help="interactively cut a commit into two smaller commits", ) return parser def interactive( args: Namespace, repo: Repository, staged: Optional[Commit], head: Reference[Commit] ): assert head.target is not None if args.target is None: base, to_rebase = local_commits(repo, head.target) else: base = repo.get_commit(args.target) to_rebase = commit_range(base, head.target) # Build up an initial todos list, edit that todos list. todos = original = build_todos(to_rebase, staged) if enable_autosquash(args, repo): todos = autosquash_todos(todos) if args.interactive: todos = edit_todos(repo, todos, msgedit=args.edit) if todos != original: # Perform the todo list actions. new_head = apply_todos(base, todos, reauthor=args.reauthor) # Update the value of HEAD to the new state. update_head(head, new_head, None) else: print("(warning) no changes performed", file=sys.stderr) def enable_autosquash(args: Namespace, repo: Repository) -> bool: if args.autosquash: return True if args.no_autosquash: return False return repo.bool_config( "revise.autoSquash", default=repo.bool_config("rebase.autoSquash", default=False), ) def noninteractive( args: Namespace, repo: Repository, staged: Optional[Commit], head: Reference[Commit] ): assert head.target is not None if args.target is None: raise ValueError(" is a required argument") head = repo.get_commit_ref(args.ref) if head.target is None: raise ValueError("Invalid target reference") current = replaced = repo.get_commit(args.target) to_rebase = commit_range(current, head.target) # Apply changes to the target commit. final = head.target.tree() if staged: print(f"Applying staged changes to '{args.target}'") current = current.update(tree=staged.rebase(current).tree()) final = staged.rebase(head.target).tree() # Update the commit message on the target commit if requested. if args.message: message = b"\n".join(l.encode("utf-8") + b"\n" for l in args.message) current = current.update(message=message) # Prompt the user to edit the commit message if requested. if args.edit: current = edit_commit_message(current) # Rewrite the author to match the current user if requested. if args.reauthor: current = current.update(author=repo.default_author) # If the commit should be cut, prompt the user to perform the cut. if args.cut: current = cut_commit(current) if current != replaced: print(f"{current.oid.short()} {current.summary()}") # Rebase commits atop the commit range. for commit in to_rebase: current = commit.rebase(current) print(f"{current.oid.short()} {current.summary()}") update_head(head, current, final) else: print("(warning) no changes performed", file=sys.stderr) def inner_main(args: Namespace, repo: Repository): # If '-a' or '-p' was specified, stage changes. if args.all: repo.git("add", "-u") if args.patch: repo.git("add", "-p") # Create a commit with changes from the index staged = None if not args.no_index: staged = repo.index.commit(message=b"") if staged.tree() == staged.parent().tree(): staged = None # No changes, ignore the commit # Determine the HEAD reference which we're going to update. head = repo.get_commit_ref(args.ref) if head.target is None: raise ValueError("Head reference not found!") # Either enter the interactive or non-interactive codepath. if args.interactive or args.autosquash: interactive(args, repo, staged, head) else: noninteractive(args, repo, staged, head) def main(argv: Optional[List[str]] = None): args = build_parser().parse_args(argv) try: with Repository() as repo: inner_main(args, repo) except CalledProcessError as err: print(f"subprocess exited with non-zero status: {err.returncode}") sys.exit(1) except EditorError as err: print(f"editor error: {err}") sys.exit(1) except MergeConflict as err: print(f"merge conflict: {err}") sys.exit(1) except ValueError as err: print(f"invalid value: {err}") sys.exit(1) git-revise-0.6.0/gitrevise/utils.py000066400000000000000000000233171366725761600173370ustar00rootroot00000000000000from typing import List, Optional, Tuple from subprocess import run, CalledProcessError from pathlib import Path import textwrap import sys import shlex import os import re from .odb import Repository, Commit, Tree, Oid, Reference class EditorError(Exception): pass def commit_range(base: Commit, tip: Commit) -> List[Commit]: """Oldest-first iterator over the given commit range, not including the commit ``base``""" commits = [] while tip != base: commits.append(tip) tip = tip.parent() commits.reverse() return commits def local_commits(repo: Repository, tip: Commit) -> Tuple[Commit, List[Commit]]: """Returns an oldest-first iterator over the local commits which are parents of the specified commit. May return an empty list. A commit is considered local if it is not present on any remote.""" # Keep track of the current base commit we're expecting. This serves two # purposes. Firstly, it lets us return a base commit to our caller, and # secondly it allows us to ensure the commits ``git log`` is producing form # a single-parent chain from our initial commit. base = tip # Call `git log` to log out the OIDs of the commits in our specified range. log = repo.git("log", base.oid.hex(), "--not", "--remotes", "--pretty=%H") # Build a list of commits, validating each commit is part of a single-parent chain. commits = [] for line in log.splitlines(): commit = repo.get_commit(Oid.fromhex(line.decode())) # Ensure the commit we got is the parent of the previous logged commit. if len(commit.parents()) != 1 or commit != base: break base = commit.parent() # Add the commit to our list. commits.append(commit) # Reverse our list into oldest-first order. commits.reverse() return base, commits def edit_file_with_editor(editor: str, path: Path) -> bytes: try: if os.name == "nt": # The popular "Git for Windows" distribution uses a bundled msys # bash executable which is generally used to invoke GIT_EDITOR. # Unfortunatly, there doesn't appear to be a way to find this # executable as a python subcommand. Instead, attempt to parse the # editor string ourselves. (#19) cmd = shlex.split(editor, posix=True) + [path.name] else: cmd = ["sh", "-c", f'{editor} "$@"', editor, path.name] run(cmd, check=True, cwd=path.parent) except CalledProcessError as err: raise EditorError(f"Editor exited with status {err}") return path.read_bytes() def get_commentchar(repo: Repository, text: bytes) -> bytes: commentchar = repo.config("core.commentChar", default=b"#") if commentchar == b"auto": chars = bytearray(b"#;@!$%^&|:") for line in text.splitlines(): try: chars.remove(line[0]) except (ValueError, IndexError): pass try: return chars[:1] except IndexError: raise EditorError("Unable to automatically select a comment character") if commentchar == b"": raise EditorError("core.commentChar must not be empty") return commentchar def strip_comments( data: bytes, commentchar: bytes, allow_preceding_whitespace: bool ) -> bytes: if allow_preceding_whitespace: pat_is_comment_line = re.compile(br"^\s*" + re.escape(commentchar)) def is_comment_line(line): return re.match(pat_is_comment_line, line) else: def is_comment_line(line): return line.startswith(commentchar) lines = b"" for line in data.splitlines(keepends=True): if not is_comment_line(line): lines += line lines = lines.rstrip() if lines != b"": lines += b"\n" return lines def run_specific_editor( editor: str, repo: Repository, filename: str, text: bytes, comments: Optional[str] = None, allow_empty: bool = False, allow_whitespace_before_comments: bool = False, ) -> bytes: """Run the editor configured for git to edit the given text""" path = repo.get_tempdir() / filename commentchar = get_commentchar(repo, text) with open(path, "wb") as handle: for line in text.splitlines(): handle.write(line + b"\n") if comments: # If comments were provided, write them after the text. handle.write(b"\n") for comment in textwrap.dedent(comments).splitlines(): handle.write(commentchar) if comment: handle.write(b" " + comment.encode("utf-8")) handle.write(b"\n") # Invoke the editor data = edit_file_with_editor(editor, path) if comments: data = strip_comments( data, commentchar, allow_preceding_whitespace=allow_whitespace_before_comments, ) # Produce an error if the file was empty if not (allow_empty or data): raise EditorError("empty file - aborting") return data def git_editor(repo: Repository) -> str: return repo.git("var", "GIT_EDITOR").decode() def edit_file(repo: Repository, path: Path) -> bytes: return edit_file_with_editor(git_editor(repo), path) def run_editor( repo: Repository, filename: str, text: bytes, comments: Optional[str] = None, allow_empty: bool = False, ) -> bytes: """Run the editor configured for git to edit the given text""" return run_specific_editor( editor=git_editor(repo), repo=repo, filename=filename, text=text, comments=comments, allow_empty=allow_empty, ) def git_sequence_editor(repo: Repository) -> str: # This lookup order replicates the one used by git itself. # See editor.c:sequence_editor. editor = os.getenv("SEQUENCE_EDITOR") if editor is None: editor_bytes = repo.config("sequence.editor", default=None) editor = editor_bytes.decode() if editor_bytes is not None else None if editor is None: editor = git_editor(repo) return editor def run_sequence_editor( repo: Repository, filename: str, text: bytes, comments: Optional[str] = None, allow_empty: bool = False, ) -> bytes: """Run the editor configured for git to edit the given rebase/revise sequence""" return run_specific_editor( editor=git_sequence_editor(repo), repo=repo, filename=filename, text=text, comments=comments, allow_empty=allow_empty, allow_whitespace_before_comments=True, ) def edit_commit_message(commit: Commit) -> Commit: """Launch an editor to edit the commit message of ``commit``, returning a modified commit""" repo = commit.repo comments = ( "Please enter the commit message for your changes. Lines starting\n" "with '#' will be ignored, and an empty message aborts the commit.\n" ) # If the target commit is not the initial commit, produce a diff --stat to # include in the commit message comments. if len(commit.parents()) == 1: tree_a = commit.parent().tree().persist().hex() tree_b = commit.tree().persist().hex() comments += "\n" + repo.git("diff-tree", "--stat", tree_a, tree_b).decode() message = run_editor(repo, "COMMIT_EDITMSG", commit.message, comments=comments) return commit.update(message=message) def update_head(ref: Reference[Commit], new: Commit, expected: Optional[Tree]): # Update the HEAD commit to point to the new value. target_oid = ref.target.oid if ref.target else Oid.null() print(f"Updating {ref.name} ({target_oid} => {new.oid})") ref.update(new, "git-revise rewrite") # We expect our tree to match the tree we started with (including index # changes). If it does not, print out a warning. if expected and new.tree() != expected: print( "(warning) unexpected final tree\n" f"(note) expected: {expected.oid}\n" f"(note) actual: {new.tree().oid}\n" "(note) working directory & index have not been updated.\n" "(note) use `git status` to see what has changed.", file=sys.stderr, ) def cut_commit(commit: Commit) -> Commit: """Perform a ``cut`` operation on the given commit, and return the modified commit.""" print(f"Cutting commit {commit.oid.short()}") print("Select changes to be included in part [1]:") base_tree = commit.parent().tree() final_tree = commit.tree() # Create an environment with an explicit index file and the base tree. # # NOTE: The use of `skip_worktree` is only necessary due to `git reset # --patch` unnecessarily invoking `git update-cache --refresh`. Doing the # extra work to set the bit greatly improves the speed of the unnecessary # refresh operation. index = base_tree.to_index( commit.repo.get_tempdir() / "TEMP_INDEX", skip_worktree=True ) # Run an interactive git-reset to allow picking which pieces of the # patch should go into the first part. index.git("reset", "--patch", final_tree.persist().hex(), "--", ".", nocapture=True) # Write out the newly created tree. mid_tree = index.tree() # Check if one or the other of the commits will be empty if mid_tree == base_tree: raise ValueError("cut part [1] is empty - aborting") if mid_tree == final_tree: raise ValueError("cut part [2] is empty - aborting") # Build the first commit part1 = commit.update(tree=mid_tree, message=b"[1] " + commit.message) part1 = edit_commit_message(part1) # Build the second commit part2 = commit.update(parents=[part1], message=b"[2] " + commit.message) part2 = edit_commit_message(part2) return part2 git-revise-0.6.0/pylintrc000066400000000000000000000005571366725761600154140ustar00rootroot00000000000000[MESSAGES CONTROL] disable= bad-continuation, missing-docstring, too-many-arguments, too-many-branches, too-many-return-statements, too-few-public-methods, too-many-instance-attributes, cyclic-import, fixme, # Currently broken analyses which are also handled (better) by mypy class-variable-slots-conflict, no-member git-revise-0.6.0/readthedocs.yml000066400000000000000000000000601366725761600166220ustar00rootroot00000000000000build: image: latest python: version: 3.6 git-revise-0.6.0/setup.py000066400000000000000000000027031366725761600153320ustar00rootroot00000000000000from pathlib import Path from setuptools import setup import gitrevise HERE = Path(__file__).resolve().parent setup( name="git-revise", version=gitrevise.__version__, packages=["gitrevise"], python_requires=">=3.6", entry_points={"console_scripts": ["git-revise = gitrevise.tui:main"]}, data_files=[("share/man/man1", ["git-revise.1"])], author="Nika Layzell", author_email="nika@thelayzells.com", description="Efficiently update, split, and rearrange git commits", long_description=(HERE / "README.md").read_text(), long_description_content_type="text/markdown", license="MIT", keywords="git revise rebase amend fixup", url="https://github.com/mystor/git-revise", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Environment :: Console", "Topic :: Software Development :: Version Control", "Topic :: Software Development :: Version Control :: Git", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", ], project_urls={ "Bug Tracker": "https://github.com/mystor/git-revise/issues/", "Source Code": "https://github.com/mystor/git-revise/", "Documentation": "https://git-revise.readthedocs.io/en/latest/", }, ) git-revise-0.6.0/tests/000077500000000000000000000000001366725761600147605ustar00rootroot00000000000000git-revise-0.6.0/tests/conftest.py000066400000000000000000000152631366725761600171660ustar00rootroot00000000000000# pylint: skip-file import pytest import shutil import shlex import os import sys import textwrap import subprocess import traceback import time from pathlib import Path from gitrevise.odb import Repository from contextlib import contextmanager from threading import Thread, Event from http.server import HTTPServer, BaseHTTPRequestHandler EDITOR_SERVER_ADDR = ("127.0.0.1", 8190) EDITOR_SCRIPT = """\ import sys from pathlib import Path from urllib.request import urlopen path = Path(sys.argv[1]).resolve() with urlopen('http://127.0.0.1:8190/', data=path.read_bytes(), timeout=5) as r: length = int(r.headers.get('content-length')) data = r.read(length) if r.status != 200: raise Exception(data.decode()) path.write_bytes(data) """ EDITOR_COMMAND = " ".join(shlex.quote(p) for p in (sys.executable, "-c", EDITOR_SCRIPT)) @pytest.fixture(autouse=True) def hermetic_seal(tmp_path_factory, monkeypatch): # Lock down user git configuration home = tmp_path_factory.mktemp("home") xdg_config_home = home / ".config" monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_home)) monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "true") # Lock down commit/authoring time monkeypatch.setenv("GIT_AUTHOR_DATE", "1500000000 -0500") monkeypatch.setenv("GIT_COMMITTER_DATE", "1500000000 -0500") # Install known configuration gitconfig = home / ".gitconfig" gitconfig.write_bytes( textwrap.dedent( """\ [core] eol = lf autocrlf = input [user] email = test@example.com name = Test User """ ).encode() ) # Install our fake editor monkeypatch.setenv("GIT_EDITOR", EDITOR_COMMAND) # Switch into a test workdir, and init our repo workdir = tmp_path_factory.mktemp("workdir") monkeypatch.chdir(workdir) bash("git init -q") @pytest.fixture def repo(hermetic_seal): with Repository() as repo: yield repo @contextmanager def in_parallel(func, *args, **kwargs): class HelperThread(Thread): exception = None def run(self): try: func(*args, **kwargs) except Exception as exc: traceback.print_exc() self.exception = exc raise thread = HelperThread() thread.start() try: yield finally: thread.join() if thread.exception: raise thread.exception def bash(command): # Use a custom environment for bash commands so commits with those commands # have unique names and emails. env = dict( os.environ, GIT_AUTHOR_NAME="Bash Author", GIT_AUTHOR_EMAIL="bash_author@example.com", GIT_COMMITTER_NAME="Bash Committer", GIT_COMMITTER_EMAIL="bash_committer@example.com", ) subprocess.run(["bash", "-ec", textwrap.dedent(command)], check=True, env=env) # Run the main entry point for git-revise in a subprocess. def main(args, **kwargs): kwargs.setdefault("check", True) cmd = [sys.executable, "-m", "gitrevise", *args] print("Running", cmd, kwargs) return subprocess.run(cmd, **kwargs) @contextmanager def editor_main(args, **kwargs): with Editor() as ed, in_parallel(main, args, **kwargs): yield ed class EditorFile(BaseHTTPRequestHandler): def __init__(self, *args, **kwargs): self.response_ready = Event() self.indata = None self.outdata = None super().__init__(*args, **kwargs) def do_POST(self): length = int(self.headers.get("content-length")) self.indata = self.rfile.read(length) self.outdata = b"" # The request is ready, tell our server, and wait for a reply. assert self.server.current is None self.server.current = self try: self.server.request_ready.set() if not self.response_ready.wait(timeout=self.server.timeout): raise Exception("timed out waiting for reply") finally: self.server.current = None def send_editor_reply(self, status, data): assert not self.response_ready.is_set(), "already replied?" self.send_response(status) self.send_header("content-length", len(data)) self.end_headers() self.wfile.write(data) self.response_ready.set() # Ensure the handle thread has shut down self.server.handle_thread.join() self.server.handle_thread = None assert self.server.current is None def startswith(self, text): return self.indata.startswith(text) def startswith_dedent(self, text): return self.startswith(textwrap.dedent(text).encode()) def equals(self, text): return self.indata == text def equals_dedent(self, text): return self.equals(textwrap.dedent(text).encode()) def replace_dedent(self, text): if isinstance(text, str): text = textwrap.dedent(text).encode() self.outdata = text def __enter__(self): return self def __exit__(self, etype, evalue, tb): if etype is None: self.send_editor_reply(200, self.outdata) else: exc = "".join(traceback.format_exception(etype, evalue, tb)).encode() try: self.send_editor_reply(500, exc) except: pass def __repr__(self): return f"" class Editor(HTTPServer): def __init__(self): super().__init__(EDITOR_SERVER_ADDR, EditorFile) self.request_ready = Event() self.handle_thread = None self.current = None self.timeout = 5 def next_file(self): assert self.handle_thread is None assert self.current is None # Spawn a thread to handle the single request. self.request_ready.clear() self.handle_thread = Thread(target=self.handle_request) self.handle_thread.start() if not self.request_ready.wait(timeout=self.timeout): raise Exception("timeout while waiting for request") # Return the request we received and were notified about. assert self.current return self.current def is_idle(self): return self.handle_thread is None and self.current is None def __enter__(self): return self def __exit__(self, etype, value, tb): try: # Only assert if we're not already raising an exception. if etype is None: assert self.is_idle() finally: self.server_close() if self.current: self.current.send_editor_reply(500, b"editor server was shut down") git-revise-0.6.0/tests/test_cut.py000066400000000000000000000021551366725761600171670ustar00rootroot00000000000000# pylint: skip-file from conftest import * def test_cut(repo): bash( """ echo "Hello, World" >> file1 git add file1 git commit -m "commit 1" echo "Append f1" >> file1 echo "Make f2" >> file2 git add file1 file2 git commit -m "commit 2" echo "Append f3" >> file2 git add file2 git commit -m "commit 3" """ ) prev = repo.get_commit("HEAD") prev_u = prev.parent() prev_uu = prev_u.parent() with editor_main(["--cut", "HEAD~"], input=b"y\nn\n") as ed: with ed.next_file() as f: assert f.startswith_dedent("[1] commit 2\n") f.replace_dedent("part 1\n") with ed.next_file() as f: assert f.startswith_dedent("[2] commit 2\n") f.replace_dedent("part 2\n") new = repo.get_commit("HEAD") new_u2 = new.parent() new_u1 = new_u2.parent() new_uu = new_u1.parent() assert prev != new assert prev.message == new.message assert new_u2.message == b"part 2\n" assert new_u1.message == b"part 1\n" assert new_uu == prev_uu git-revise-0.6.0/tests/test_fixup.py000066400000000000000000000165131366725761600175320ustar00rootroot00000000000000# pylint: skip-file from conftest import * import os @pytest.fixture def basic_repo(repo): bash( """ cat <file1 Hello, World! How are things? EOF git add file1 git commit -m "commit1" cat <file1 Hello, World! Oops, gotta add a new line! How are things? EOF git add file1 git commit -m "commit2" """ ) return repo def fixup_helper(repo, flags, target, message=None): old = repo.get_commit(target) assert old.persisted bash( """ echo "extra line" >> file1 git add file1 """ ) main(flags + [target]) new = repo.get_commit(target) assert old != new, "commit was modified" assert old.parents() == new.parents(), "parents are unchanged" assert old.tree() != new.tree(), "tree is changed" if message is None: assert new.message == old.message, "message should not be changed" else: assert new.message == message.encode(), "message set correctly" assert new.persisted, "commit persisted to disk" assert new.author == old.author, "author is unchanged" assert new.committer == repo.default_committer, "committer is updated" def test_fixup_head(basic_repo): fixup_helper(basic_repo, [], "HEAD") def test_fixup_nonhead(basic_repo): fixup_helper(basic_repo, [], "HEAD~") def test_fixup_head_msg(basic_repo): fixup_helper( basic_repo, ["-m", "fixup_head test", "-m", "another line"], "HEAD", "fixup_head test\n\nanother line\n", ) def test_fixup_nonhead_msg(basic_repo): fixup_helper( basic_repo, ["-m", "fixup_nonhead test", "-m", "another line"], "HEAD~", "fixup_nonhead test\n\nanother line\n", ) def test_fixup_head_editor(basic_repo): old = basic_repo.get_commit("HEAD") newmsg = "fixup_head_editor test\n\nanother line\n" with Editor() as ed, in_parallel(fixup_helper, basic_repo, ["-e"], "HEAD", newmsg): with ed.next_file() as f: assert f.startswith(old.message) f.replace_dedent(newmsg) def test_fixup_nonhead_editor(basic_repo): old = basic_repo.get_commit("HEAD~") newmsg = "fixup_nonhead_editor test\n\nanother line\n" with Editor() as ed, in_parallel(fixup_helper, basic_repo, ["-e"], "HEAD~", newmsg): with ed.next_file() as f: assert f.startswith(old.message) f.replace_dedent(newmsg) def test_fixup_nonhead_conflict(basic_repo): import textwrap bash('echo "conflict" > file1') bash("git add file1") old = basic_repo.get_commit("HEAD~") assert old.persisted with editor_main(["HEAD~"], input=b"y\ny\ny\ny\n") as ed: with ed.next_file() as f: assert f.equals_dedent( f"""\ <<<<<<< {os.sep}file1 (new parent): commit1 Hello, World! How are things? ======= conflict >>>>>>> {os.sep}file1 (current): """ ) f.replace_dedent("conflict1\n") with ed.next_file() as f: assert f.equals_dedent( f"""\ <<<<<<< {os.sep}file1 (new parent): commit1 conflict1 ======= Hello, World! Oops, gotta add a new line! How are things? >>>>>>> {os.sep}file1 (current): commit2 """ ) f.replace_dedent("conflict2\n") new = basic_repo.get_commit("HEAD~") assert new.persisted assert new != old def test_autosquash_nonhead(repo): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" echo "extra line" >> file2 git add file2 git commit --fixup=HEAD~ """ ) old = repo.get_commit("HEAD~~") assert old.persisted main(["--autosquash", str(old.parent().oid)]) new = repo.get_commit("HEAD~") assert old != new, "commit was modified" assert old.parents() == new.parents(), "parents are unchanged" assert old.tree() != new.tree(), "tree is changed" assert new.message == old.message, "message should not be changed" assert new.persisted, "commit persisted to disk" assert new.author == old.author, "author is unchanged" assert new.committer == repo.default_committer, "committer is updated" file1 = new.tree().entries[b"file1"].blob().body assert file1 == b"hello, world\n" file2 = new.tree().entries[b"file2"].blob().body assert file2 == b"second file\nextra line\n" def test_fixup_of_fixup(repo): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" echo "extra line" >> file2 git add file2 git commit --fixup=HEAD~ echo "even more" >> file2 git add file2 git commit --fixup=HEAD """ ) old = repo.get_commit("HEAD~~~") assert old.persisted main(["--autosquash", str(old.parent().oid)]) new = repo.get_commit("HEAD~") assert old != new, "commit was modified" assert old.parents() == new.parents(), "parents are unchanged" assert old.tree() != new.tree(), "tree is changed" assert new.message == old.message, "message should not be changed" assert new.persisted, "commit persisted to disk" assert new.author == old.author, "author is unchanged" assert new.committer == repo.default_committer, "committer is updated" file1 = new.tree().entries[b"file1"].blob().body assert file1 == b"hello, world\n" file2 = new.tree().entries[b"file2"].blob().body assert file2 == b"second file\nextra line\neven more\n" def test_fixup_by_id(repo): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" echo "extra line" >> file2 git add file2 git commit -m "fixup! $(git rev-parse HEAD~)" """ ) old = repo.get_commit("HEAD~~") assert old.persisted main(["--autosquash", str(old.parent().oid)]) new = repo.get_commit("HEAD~") assert old != new, "commit was modified" assert old.parents() == new.parents(), "parents are unchanged" assert old.tree() != new.tree(), "tree is changed" assert new.message == old.message, "message should not be changed" assert new.persisted, "commit persisted to disk" assert new.author == old.author, "author is unchanged" assert new.committer == repo.default_committer, "committer is updated" file1 = new.tree().entries[b"file1"].blob().body assert file1 == b"hello, world\n" file2 = new.tree().entries[b"file2"].blob().body assert file2 == b"second file\nextra line\n" git-revise-0.6.0/tests/test_interactive.py000066400000000000000000000170261366725761600207140ustar00rootroot00000000000000# pylint: skip-file import textwrap import pytest from conftest import * def interactive_reorder_helper(repo, cwd): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" """ ) prev = repo.get_commit("HEAD") prev_u = prev.parent() prev_uu = prev_u.parent() with editor_main(["-i", "HEAD~~"], cwd=cwd) as ed: with ed.next_file() as f: assert f.startswith_dedent( f"""\ pick {prev.parent().oid.short()} commit two pick {prev.oid.short()} commit three """ ) f.replace_dedent( f"""\ pick {prev.oid.short()} commit three pick {prev.parent().oid.short()} commit two """ ) curr = repo.get_commit("HEAD") curr_u = curr.parent() curr_uu = curr_u.parent() assert curr != prev assert curr.tree() == prev.tree() assert curr_u.message == prev.message assert curr.message == prev_u.message assert curr_uu == prev_uu assert b"file2" in prev_u.tree().entries assert b"file2" not in curr_u.tree().entries assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] def test_interactive_reorder(repo): interactive_reorder_helper(repo, cwd=repo.workdir) def test_interactive_reorder_subdir(repo): bash("mkdir subdir") interactive_reorder_helper(repo, cwd=repo.workdir / "subdir") def test_interactive_fixup(repo): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" echo "extra" >> file3 git add file3 """ ) prev = repo.get_commit("HEAD") prev_u = prev.parent() prev_uu = prev_u.parent() index_tree = repo.index.tree() with editor_main(["-i", "HEAD~~"]) as ed: with ed.next_file() as f: index = repo.index.commit() assert f.startswith_dedent( f"""\ pick {prev.parent().oid.short()} commit two pick {prev.oid.short()} commit three index {index.oid.short()} """ ) f.replace_dedent( f"""\ pick {prev.oid.short()} commit three fixup {index.oid.short()} pick {prev.parent().oid.short()} commit two """ ) curr = repo.get_commit("HEAD") curr_u = curr.parent() curr_uu = curr_u.parent() assert curr != prev assert curr.tree() == index_tree assert curr_u.message == prev.message assert curr.message == prev_u.message assert curr_uu == prev_uu assert b"file2" in prev_u.tree().entries assert b"file2" not in curr_u.tree().entries assert b"file3" not in prev.tree().entries assert b"file3" not in prev_u.tree().entries assert b"file3" not in prev_uu.tree().entries assert b"file3" in curr.tree().entries assert b"file3" in curr_u.tree().entries assert b"file3" not in curr_uu.tree().entries assert curr.tree().entries[b"file3"].blob().body == b"extra\n" assert curr_u.tree().entries[b"file3"].blob().body == b"extra\n" assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] @pytest.mark.parametrize( "rebase_config,revise_config,expected", [ (None, None, False), ("1", "0", False), ("0", "1", True), ("1", None, True), (None, "1", True), ], ) def test_autosquash_config(repo, rebase_config, revise_config, expected): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" echo "second file" > file2 git add file2 git commit -m "commit two" echo "new line!" >> file1 git add file1 git commit -m "commit three" echo "extra line" >> file2 git add file2 git commit --fixup=HEAD~ """ ) if rebase_config is not None: bash(f"git config rebase.autoSquash '{rebase_config}'") if revise_config is not None: bash(f"git config revise.autoSquash '{revise_config}'") head = repo.get_commit("HEAD") headu = head.parent() headuu = headu.parent() disabled = f"""\ pick {headuu.oid.short()} commit two pick {headu.oid.short()} commit three pick {head.oid.short()} fixup! commit two """ enabled = f"""\ pick {headuu.oid.short()} commit two fixup {head.oid.short()} fixup! commit two pick {headu.oid.short()} commit three """ def subtest(args, expected_todos): with editor_main(args + ["-i", "HEAD~3"]) as ed: with ed.next_file() as f: assert f.startswith_dedent(expected_todos) f.replace_dedent(disabled) # don't mutate state assert repo.get_commit("HEAD") == head subtest([], enabled if expected else disabled) subtest(["--autosquash"], enabled) subtest(["--no-autosquash"], disabled) def test_interactive_reword(repo): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit one" -m "extended1" echo "second file" > file2 git add file2 git commit -m "commit two" -m "extended2" echo "new line!" >> file1 git add file1 git commit -m "commit three" -m "extended3" """ ) prev = repo.get_commit("HEAD") prev_u = prev.parent() prev_uu = prev_u.parent() with editor_main(["-ie", "HEAD~~"]) as ed: with ed.next_file() as f: assert f.startswith_dedent( f"""\ ++ pick {prev.parent().oid.short()} commit two extended2 ++ pick {prev.oid.short()} commit three extended3 """ ) f.replace_dedent( f"""\ ++ pick {prev.oid.short()} updated commit three extended3 updated ++ pick {prev.parent().oid.short()} updated commit two extended2 updated """ ) curr = repo.get_commit("HEAD") curr_u = curr.parent() curr_uu = curr_u.parent() assert curr != prev assert curr.tree() == prev.tree() assert curr_u.message == b"updated commit three\n\nextended3 updated\n" assert curr.message == b"updated commit two\n\nextended2 updated\n" assert curr_uu == prev_uu assert b"file2" in prev_u.tree().entries assert b"file2" not in curr_u.tree().entries assert prev_u.tree().entries[b"file2"] == curr.tree().entries[b"file2"] assert prev_u.tree().entries[b"file1"] == curr_uu.tree().entries[b"file1"] assert prev.tree().entries[b"file1"] == curr_u.tree().entries[b"file1"] git-revise-0.6.0/tests/test_reword.py000066400000000000000000000026211366725761600176740ustar00rootroot00000000000000# pylint: skip-file import textwrap from conftest import * @pytest.mark.parametrize("target", ["HEAD", "HEAD~", "HEAD~~"]) @pytest.mark.parametrize("use_editor", [True, False]) def test_reword(repo, target, use_editor): bash( """ echo "hello, world" > file1 git add file1 git commit -m "commit 1" echo "new line!" >> file1 git add file1 git commit -m "commit 2" echo "yet another line!" >> file1 git add file1 git commit -m "commit 3" """ ) message = textwrap.dedent( """\ reword test another line """ ).encode() old = repo.get_commit(target) assert old.message != message assert old.persisted if use_editor: with editor_main(["--no-index", "-e", target]) as ed: with ed.next_file() as f: assert f.startswith(old.message) f.replace_dedent(message) else: main(["--no-index", "-m", "reword test", "-m", "another line", target]) new = repo.get_commit(target) assert old != new, "commit was modified" assert old.tree() == new.tree(), "tree is unchanged" assert old.parents() == new.parents(), "parents are unchanged" assert new.message == message, "message set correctly" assert new.persisted, "commit persisted to disk" assert new.author == old.author, "author is unchanged" git-revise-0.6.0/tox.ini000066400000000000000000000015361366725761600151360ustar00rootroot00000000000000# Tox (http://tox.testrun.org/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. [tox] envlist = py36 py37 py38 mypy lint format [testenv] description = pytest for {basepython} commands = pytest {posargs} deps = pytest [testenv:mypy] description = typecheck with mypy commands = mypy gitrevise {posargs} basepython = python3.8 deps = mypy [testenv:lint] description = lint with pylint commands = pylint gitrevise {posargs} basepython = python3.8 deps = pylint >= 2.4 [testenv:format] description = validate formatting commands = black --check . {posargs} basepython = python3.8 deps = black [travis] python = 3.6: py36 3.7: py37 3.8: py38, mypy, lint, format