From 9d8d89eed49cafbe5457f7fa7370d812f5b8e27b Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 22 May 2023 10:22:20 +0000 Subject: [PATCH] Support pathspecs in --cut Sometimes I want to extract a small number of changes from a commit that touches loads of files. Since the cut mode prompts about every single file, there is no convenient way to do this. Teach --cut about pathspec arguments. This allows to use git revise --cut my-commit -- path/to/my/file a to extract to a separate commit all changes in the given file. In future we could support pathspecs in other subcommands. --- docs/man.rst | 6 +++-- gitrevise/tui.py | 14 +++++++--- gitrevise/utils.py | 12 +++++++-- tests/test_cut.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_edit.py | 12 ++++----- 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/docs/man.rst b/docs/man.rst index 27cc86e..a9f0bef 100644 --- a/docs/man.rst +++ b/docs/man.rst @@ -10,6 +10,7 @@ SYNOPSIS ======== *git revise* [] [] +*git revise* [] --cut [--] [...] DESCRIPTION =========== @@ -93,8 +94,9 @@ Main modes of operation .. option:: -c, --cut - Interactively select hunks from . The chosen hunks are split into - a second commit immediately after the target. + Interactively select hunks from , optionally limited by . + The chosen hunks are split into a second commit immediately after the + target. After splitting is complete, both commits' messages are edited. diff --git a/gitrevise/tui.py b/gitrevise/tui.py index 4c24cd9..a54981f 100644 --- a/gitrevise/tui.py +++ b/gitrevise/tui.py @@ -19,7 +19,7 @@ ) -def build_parser() -> ArgumentParser: +def build_parser(allow_pathspecs: bool) -> ArgumentParser: parser = ArgumentParser( description="""\ Rebase staged changes onto the given commit, and rewrite history to @@ -36,6 +36,12 @@ def build_parser() -> ArgumentParser: nargs="?", help="target commit to apply fixups to", ) + if allow_pathspecs: + parser.add_argument( + "pathspecs", + nargs="*", + help="make --cut select only from matching files", + ) parser.add_argument("--ref", default="HEAD", help="reference to update") parser.add_argument( "--reauthor", @@ -201,7 +207,7 @@ def noninteractive( # If the commit should be cut, prompt the user to perform the cut. if args.cut: - current = cut_commit(current) + current = cut_commit(current, args.pathspecs) # Add or remove GPG signatures. if repo.sign_commits != bool(current.gpgsig): @@ -258,7 +264,9 @@ def inner_main(args: Namespace, repo: Repository) -> None: def main(argv: Optional[List[str]] = None) -> None: - args = build_parser().parse_args(argv) + args = build_parser(allow_pathspecs=True).parse_args(argv) + if not args.cut: + args = build_parser(allow_pathspecs=False).parse_args(argv) try: with Repository() as repo: inner_main(args, repo) diff --git a/gitrevise/utils.py b/gitrevise/utils.py index cdf6725..f6a897e 100644 --- a/gitrevise/utils.py +++ b/gitrevise/utils.py @@ -252,7 +252,7 @@ def update_head(ref: Reference[Commit], new: Commit, expected: Optional[Tree]) - ) -def cut_commit(commit: Commit) -> Commit: +def cut_commit(commit: Commit, pathspecs: Optional[List[str]] = None) -> Commit: """Perform a ``cut`` operation on the given commit, and return the modified commit.""" @@ -274,7 +274,15 @@ def cut_commit(commit: Commit) -> Commit: # 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(), "--", ".", stdout=None) + index.git( + "reset", + "--patch", + final_tree.persist().hex(), + "--", + *(pathspecs if pathspecs else []), + cwd=Path.cwd(), + stdout=None, + ) # Write out the newly created tree. mid_tree = index.tree() diff --git a/tests/test_cut.py b/tests/test_cut.py index c42bc03..f999fd4 100644 --- a/tests/test_cut.py +++ b/tests/test_cut.py @@ -1,3 +1,7 @@ +from textwrap import dedent + +import pytest + from gitrevise.odb import Repository from .conftest import bash, editor_main @@ -84,3 +88,65 @@ def test_cut_root(repo: Repository) -> None: assert new_u != new assert new_u != prev + + +@pytest.mark.parametrize("pathspec", ["subdir/file2", "file2"]) +def test_cut_pathspec( + repo: Repository, monkeypatch: pytest.MonkeyPatch, pathspec: str +) -> None: + bash( + """ + echo "Hello, World" >> file1 + git add file1 + git commit -m "commit 1" + + echo "Append f1" >> file1 + mkdir subdir + echo "Make f2" >> subdir/file2 + git add file1 subdir/file2 + git commit -m "commit 2" + """ + ) + + if pathspec == "file2": + monkeypatch.chdir(repo.workdir / "subdir") + with editor_main(["--cut", "HEAD", pathspec], input=b"y\n") as ed: + with ed.next_file() as f: + assert f.startswith_dedent("[1] commit 2\n") + f.replace_dedent("extracted changes\n") + + with ed.next_file() as f: + assert f.startswith_dedent("[2] commit 2\n") + f.replace_dedent("remaining changes\n") + + assert ( + repo.git("show", "HEAD~", "--format=%s").decode() + == dedent( + """ + extracted changes + + diff --git a/subdir/file2 b/subdir/file2 + new file mode 100644 + index 0000000..93350fe + --- /dev/null + +++ b/subdir/file2 + @@ -0,0 +1 @@ + +Make f2""" + )[1:] + ) + + assert ( + repo.git("show", "HEAD", "--format=%s").decode() + == dedent( + """ + remaining changes + + diff --git a/file1 b/file1 + index 3fa0d4b..ada44cf 100644 + --- a/file1 + +++ b/file1 + @@ -1 +1,2 @@ + Hello, World + +Append f1""" + )[1:] + ) diff --git a/tests/test_edit.py b/tests/test_edit.py index 1e64176..d1c797b 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -1,12 +1,10 @@ -from subprocess import CalledProcessError - -import pytest - from gitrevise.odb import Repository from .conftest import main + +# pylint: disable=unused-argument def test_edit_illegal_extra_argument(repo: Repository) -> None: - p = main(["--edit", "HEAD", "HEAD"], check=False, capture_output=True) - assert b'error: unrecognized arguments' in p.stderr - assert p.returncode != 0 + process = main(["--edit", "HEAD", "HEAD"], check=False, capture_output=True) + assert b"error: unrecognized arguments" in process.stderr + assert process.returncode != 0