Repository: mystor/git-revise

Issue:
Add `--root` option
When using `git rebase`, one can add the `--root` options to allow editing even the very first commit in a repository.

I would like to have a way to do the same with `git revise`, I even tried using the semi-famous empty tree hash-id, without success.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4dd787f..3698e9c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 * Fix autosquash order of multiple fixup commits with the same target (#72)
 * Use `GIT_SEQUENCE_EDITOR` instead of `SEQUENCE_EDITOR` (#71)
 * Fix handling of multiline commit subjects (#86)
+* Add support for interactively revising or autosquashing the root commit via `--root`
 
 ## v0.6.0
 
diff --git a/docs/man.rst b/docs/man.rst
index e47e285..97d2725 100644
--- a/docs/man.rst
+++ b/docs/man.rst
@@ -29,8 +29,10 @@ 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
-<target> argument is optional. If it is omitted, :program:`git revise` will
-consider a range of unpublished commits on the current branch.
+<target> argument may be omitted or given as the special value `:option:--root`.
+If it is omitted, :program:`git revise` will consider a range of unpublished
+commits on the current branch. If given as `:option:--root`, all commits
+including the root commit will be considered.
 
 OPTIONS
 =======
@@ -170,6 +172,13 @@ This mode is started with the last commit you want to retain "as-is":
 
     git revise -i <after-this-commit>
 
+The special target `--root` is available to revise everything up to the root
+commit:
+
+.. code-block:: bash
+
+    git revise -i --root
+
 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 ``<git
 index>`` entry will also be present.
diff --git a/gitrevise/merge.py b/gitrevise/merge.py
index 545d454..5a8f520 100644
--- a/gitrevise/merge.py
+++ b/gitrevise/merge.py
@@ -28,21 +28,34 @@ class MergeConflict(Exception):
     pass
 
 
-def rebase(commit: Commit, parent: Commit) -> Commit:
-    if commit.parent() == parent:
+def rebase(commit: Commit, new_parent: Optional[Commit]) -> Commit:
+    repo = commit.repo
+
+    orig_parent = commit.parent() if not commit.is_root else None
+
+    if orig_parent == new_parent:
         return commit  # No need to do anything
 
+    def get_summary(cmt: Optional[Commit]) -> str:
+        return cmt.summary() if cmt is not None else "<root>"
+
+    def get_tree(cmt: Optional[Commit]) -> Tree:
+        return cmt.tree() if cmt is not None else Tree(repo, b"")
+
     tree = merge_trees(
         Path("/"),
-        (parent.summary(), commit.parent().summary(), commit.summary()),
-        parent.tree(),
-        commit.parent().tree(),
-        commit.tree(),
+        (get_summary(new_parent), get_summary(orig_parent), get_summary(commit)),
+        get_tree(new_parent),
+        get_tree(orig_parent),
+        get_tree(commit),
     )
+
+    new_parents = [new_parent] if new_parent is not None else []
+
     # 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)
+    return commit.update(tree=tree, parents=new_parents)
 
 
 def conflict_prompt(
diff --git a/gitrevise/odb.py b/gitrevise/odb.py
index 8e7b315..abd610b 100644
--- a/gitrevise/odb.py
+++ b/gitrevise/odb.py
@@ -506,6 +506,18 @@ class Commit(GitObj):
         """``tree`` object corresponding to this commit"""
         return self.repo.get_tree(self.tree_oid)
 
+    def parent_tree(self) -> "Tree":
+        """``tree`` object corresponding to the first parent of this commit,
+        or the null tree if this is a root commit"""
+        if self.is_root:
+            return Tree(self.repo, b"")
+        return self.parents()[0].tree()
+
+    @property
+    def is_root(self) -> bool:
+        """Whether this commit has no parents"""
+        return not self.parent_oids
+
     def parents(self) -> Sequence["Commit"]:
         """List of parent commits"""
         return [self.repo.get_commit(parent) for parent in self.parent_oids]
@@ -526,9 +538,9 @@ class Commit(GitObj):
         )
         return " ".join(summary_paragraph.splitlines())
 
-    def rebase(self, parent: "Commit") -> "Commit":
+    def rebase(self, parent: Optional["Commit"]) -> "Commit":
         """Create a new commit with the same changes, except with ``parent``
-        as it's parent."""
+        as its parent. If ``parent`` is ``None``, this becomes a root commit."""
         from .merge import rebase  # pylint: disable=import-outside-toplevel
 
         return rebase(self, parent)
diff --git a/gitrevise/todo.py b/gitrevise/todo.py
index f340bd5..b2cb38e 100644
--- a/gitrevise/todo.py
+++ b/gitrevise/todo.py
@@ -240,16 +240,24 @@ def edit_todos(
     return result
 
 
-def apply_todos(current: Commit, todos: List[Step], reauthor: bool = False) -> Commit:
+def apply_todos(
+    current: Optional[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:
+            if current is None:
+                raise ValueError("Cannot apply fixup as first commit")
             current = current.update(tree=rebased.tree())
         elif step.kind == StepKind.REWORD:
             current = edit_commit_message(rebased)
         elif step.kind == StepKind.SQUASH:
+            if current is None:
+                raise ValueError("Cannot apply squash as first commit")
             fused = current.message + b"\n\n" + rebased.message
             current = current.update(tree=rebased.tree(), message=fused)
             current = edit_commit_message(current)
@@ -265,4 +273,7 @@ def apply_todos(current: Commit, todos: List[Step], reauthor: bool = False) -> C
 
         print(f"{step.kind.value:6} {current.oid.short()}  {current.summary()}")
 
+    if current is None:
+        raise ValueError("No commits introduced on top of root commit")
+
     return current
diff --git a/gitrevise/tui.py b/gitrevise/tui.py
index 8734522..3d77cc3 100644
--- a/gitrevise/tui.py
+++ b/gitrevise/tui.py
@@ -23,7 +23,17 @@ def build_parser() -> ArgumentParser:
         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")
+    target_group = parser.add_mutually_exclusive_group()
+    target_group.add_argument(
+        "--root",
+        action="store_true",
+        help="revise starting at the root commit",
+    )
+    target_group.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",
@@ -96,11 +106,11 @@ def interactive(
 ) -> None:
     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)
+    if args.target or args.root:
+        base = repo.get_commit(args.target) if args.target else None
         to_rebase = commit_range(base, head.target)
+    else:
+        base, to_rebase = local_commits(repo, head.target)
 
     # Build up an initial todos list, edit that todos list.
     todos = original = build_todos(to_rebase, staged)
@@ -138,6 +148,12 @@ def noninteractive(
 ) -> None:
     assert head.target is not None
 
+    if args.root:
+        raise ValueError(
+            "Incompatible option: "
+            "--root may only be used with --autosquash or --interactive"
+        )
+
     if args.target is None:
         raise ValueError("<target> is a required argument")
 
@@ -196,7 +212,7 @@ def inner_main(args: Namespace, repo: Repository) -> None:
     staged = None
     if not args.no_index:
         staged = repo.index.commit(message=b"<git index>")
-        if staged.tree() == staged.parent().tree():
+        if staged.tree() == staged.parent_tree():
             staged = None  # No changes, ignore the commit
 
     # Determine the HEAD reference which we're going to update.
diff --git a/gitrevise/utils.py b/gitrevise/utils.py
index 2311b02..d1c6b2e 100644
--- a/gitrevise/utils.py
+++ b/gitrevise/utils.py
@@ -14,12 +14,14 @@ class EditorError(Exception):
     pass
 
 
-def commit_range(base: Commit, tip: Commit) -> List[Commit]:
+def commit_range(base: Optional[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)
+        if tip.is_root and base is None:
+            break
         tip = tip.parent()
     commits.reverse()
     return commits
@@ -224,10 +226,10 @@ def edit_commit_message(commit: Commit) -> Commit:
         "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
+    # If the target commit is not a merge commit, produce a diff --stat to
     # include in the commit message comments.
-    if len(commit.parents()) == 1:
-        tree_a = commit.parent().tree().persist().hex()
+    if len(commit.parents()) < 2:
+        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()
 
@@ -261,7 +263,7 @@ def cut_commit(commit: Commit) -> Commit:
     print(f"Cutting commit {commit.oid.short()}")
     print("Select changes to be included in part [1]:")
 
-    base_tree = commit.parent().tree()
+    base_tree = commit.parent_tree()
     final_tree = commit.tree()
 
     # Create an environment with an explicit index file and the base tree.


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4a74398..ea6a72c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,36 +1,17 @@
 # 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))
+import dummy_editor
 
 
 @pytest.fixture(autouse=True)
@@ -61,8 +42,10 @@ def hermetic_seal(tmp_path_factory, monkeypatch):
         ).encode()
     )
 
-    # Install our fake editor
-    monkeypatch.setenv("GIT_EDITOR", EDITOR_COMMAND)
+    # If we are not expecting an editor to be launched, abort immediately.
+    # (The `false` command always exits with failure).
+    # This is overridden in editor_main.
+    monkeypatch.setenv("GIT_EDITOR", "false")
 
     # Switch into a test workdir, and init our repo
     workdir = tmp_path_factory.mktemp("workdir")
@@ -130,8 +113,19 @@ def main(args, **kwargs):
 
 @contextmanager
 def editor_main(args, **kwargs):
-    with Editor() as ed, in_parallel(main, args, **kwargs):
-        yield ed
+    with pytest.MonkeyPatch().context() as m, Editor() as ed:
+        editor_cmd = " ".join(
+            shlex.quote(p)
+            for p in (
+                sys.executable,
+                dummy_editor.__file__,
+                "http://{0}:{1}/".format(*ed.server_address[:2]),
+            )
+        )
+        m.setenv("GIT_EDITOR", editor_cmd)
+
+        with in_parallel(main, args, **kwargs):
+            yield ed
 
 
 class EditorFile(BaseHTTPRequestHandler):
@@ -205,11 +199,12 @@ class EditorFile(BaseHTTPRequestHandler):
 
 class Editor(HTTPServer):
     def __init__(self):
-        super().__init__(EDITOR_SERVER_ADDR, EditorFile)
+        # Bind to a randomly-allocated free port.
+        super().__init__(("127.0.0.1", 0), EditorFile)
         self.request_ready = Event()
         self.handle_thread = None
         self.current = None
-        self.timeout = 5
+        self.timeout = 10
 
     def next_file(self):
         assert self.handle_thread is None
diff --git a/tests/dummy_editor.py b/tests/dummy_editor.py
new file mode 100644
index 0000000..d0a7e0c
--- /dev/null
+++ b/tests/dummy_editor.py
@@ -0,0 +1,17 @@
+import sys
+from pathlib import Path
+from urllib.request import urlopen
+
+
+def run_editor(path: Path, url: str) -> None:
+    # pylint: disable=invalid-name
+    with urlopen(url, data=path.read_bytes(), timeout=10) 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)
+
+
+if __name__ == "__main__":
+    run_editor(url=sys.argv[1], path=Path(sys.argv[2]).resolve())
diff --git a/tests/test_cut.py b/tests/test_cut.py
index 122b404..df7842f 100644
--- a/tests/test_cut.py
+++ b/tests/test_cut.py
@@ -44,3 +44,43 @@ def test_cut(repo):
     assert new_u2.message == b"part 2\n"
     assert new_u1.message == b"part 1\n"
     assert new_uu == prev_uu
+
+
+def test_cut_root(repo):
+    bash(
+        """
+        echo "Hello, World" >> file1
+        echo "Make f2" >> file2
+        git add file1 file2
+        git commit -m "root commit"
+        """
+    )
+
+    prev = repo.get_commit("HEAD")
+    assert prev.is_root
+    assert len(prev.parent_oids) == 0
+
+    with editor_main(["--cut", "HEAD"], input=b"y\nn\n") as ed:
+        with ed.next_file() as f:
+            assert f.startswith_dedent("[1] root commit\n")
+            f.replace_dedent("part 1\n")
+
+        with ed.next_file() as f:
+            assert f.startswith_dedent("[2] root commit\n")
+            f.replace_dedent("part 2\n")
+
+    new = repo.get_commit("HEAD")
+    assert new != prev
+    assert new.message == b"part 2\n"
+
+    assert not new.is_root
+    assert len(new.parent_oids) == 1
+
+    new_u = new.parent()
+    assert new_u.message == b"part 1\n"
+    assert new_u.is_root
+    assert len(new_u.parent_oids) == 0
+    assert new_u.parent_tree() == prev.parent_tree()
+
+    assert new_u != new
+    assert new_u != prev
diff --git a/tests/test_fixup.py b/tests/test_fixup.py
index d0b84d8..409a3e8 100644
--- a/tests/test_fixup.py
+++ b/tests/test_fixup.py
@@ -30,6 +30,12 @@ def basic_repo(repo):
 
 
 def fixup_helper(repo, flags, target, message=None):
+    with fixup_helper_editor(repo=repo, flags=flags, target=target, message=message):
+        pass
+
+
+@contextmanager
+def fixup_helper_editor(repo, flags, target, message=None):
     old = repo.get_commit(target)
     assert old.persisted
 
@@ -40,7 +46,8 @@ def fixup_helper(repo, flags, target, message=None):
         """
     )
 
-    main(flags + [target])
+    with editor_main(flags + [target]) as ed:
+        yield ed
 
     new = repo.get_commit(target)
     assert old != new, "commit was modified"
@@ -88,7 +95,7 @@ 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 fixup_helper_editor(basic_repo, ["-e"], "HEAD", newmsg) as ed:
         with ed.next_file() as f:
             assert f.startswith(old.message)
             f.replace_dedent(newmsg)
@@ -98,7 +105,7 @@ 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 fixup_helper_editor(basic_repo, ["-e"], "HEAD~", newmsg) as ed:
         with ed.next_file() as f:
             assert f.startswith(old.message)
             f.replace_dedent(newmsg)
diff --git a/tests/test_interactive.py b/tests/test_interactive.py
index 06d8b62..d9431cf 100644
--- a/tests/test_interactive.py
+++ b/tests/test_interactive.py
@@ -1,6 +1,5 @@
 # pylint: skip-file
 
-import textwrap
 import pytest
 from conftest import *
 
@@ -68,6 +67,65 @@ def test_interactive_reorder_subdir(repo):
     interactive_reorder_helper(repo, cwd=repo.workdir / "subdir")
 
 
+def test_interactive_on_root(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"
+        """
+    )
+
+    orig_commit3 = prev = repo.get_commit("HEAD")
+    orig_commit2 = prev_u = prev.parent()
+    orig_commit1 = prev_u.parent()
+
+    index_tree = repo.index.tree()
+
+    with editor_main(["-i", "--root"]) as ed:
+        with ed.next_file() as f:
+            assert f.startswith_dedent(
+                f"""\
+                pick {prev.parent().parent().oid.short()} commit one
+                pick {prev.parent().oid.short()} commit two
+                pick {prev.oid.short()} commit three
+                """
+            )
+            f.replace_dedent(
+                f"""\
+                pick {prev.parent().oid.short()} commit two
+                pick {prev.parent().parent().oid.short()} commit one
+                pick {prev.oid.short()} commit three
+                """
+            )
+
+    new_commit3 = curr = repo.get_commit("HEAD")
+    new_commit1 = curr_u = curr.parent()
+    new_commit2 = curr_u.parent()
+
+    assert curr != prev
+    assert curr.tree() == index_tree
+    assert new_commit1.message == orig_commit1.message
+    assert new_commit2.message == orig_commit2.message
+    assert new_commit3.message == orig_commit3.message
+
+    assert new_commit2.is_root
+    assert new_commit1.parent() == new_commit2
+    assert new_commit3.parent() == new_commit1
+
+    assert new_commit1.tree().entries[b"file1"] == orig_commit1.tree().entries[b"file1"]
+    assert new_commit2.tree().entries[b"file2"] == orig_commit2.tree().entries[b"file2"]
+    assert new_commit3.tree() == orig_commit3.tree()
+
+
 def test_interactive_fixup(repo):
     bash(
         """