diff --git a/docs/rbtools/index.rst b/docs/rbtools/index.rst
index 162c1f1ae8f9c0cf37844cdd4bed4a0dd0614f9a..e3062d3eada60a7e6911daaafc2c32725b60758d 100644
--- a/docs/rbtools/index.rst
+++ b/docs/rbtools/index.rst
@@ -148,6 +148,7 @@ common workflows to help you get started:
 * :ref:`rbtools-workflow-sos`
 * :ref:`rbtools-workflow-git`
 * :ref:`rbtools-workflow-clearcase`
+* :ref:`rbtools-workflow-jujutsu`
 * :ref:`rbtools-workflow-perforce`
 * :ref:`Working with Team Foundation Server <rbtools-tfs>`
 
diff --git a/docs/rbtools/rbt/commands/post.rst b/docs/rbtools/rbt/commands/post.rst
index e379fe034aad9a8e88e8e575b82d60c7c7dccf1d..4a5ba4b82c45d9824d5795c3ba6e339072847901 100644
--- a/docs/rbtools/rbt/commands/post.rst
+++ b/docs/rbtools/rbt/commands/post.rst
@@ -142,6 +142,7 @@ the review request, you can include them on the command line, like so:
 
 .. _DVCS:
 .. _rbt-post-git:
+.. _rbt-post-jujutsu:
 .. _rbt-post-mercurial:
 
 Distributed Version Control Systems
@@ -194,18 +195,25 @@ If you want to upload a diff of everything between ``topicA`` and ``topicB``,
 you would need to tell :command:`rbt post` to also generate a parent diff
 between ``master`` and ``topicA``.
 
-This is done by using the :option:`--parent` parameter with the branch name.
-For example, in this case you would simply do:
+This is done by passing the two revisions as arguments. For example, in this
+case you would simply do:
 
 .. code-block:: console
 
-    $ rbt post --parent=topicA
+    $ rbt post topicA topicB
 
 That would generate a parent diff between ``master`` and ``topicA``, and
 a normal diff of your changes between ``topicA`` and ``topicB``. The changes
 in the parent diff won't appear as changed lines in the diff viewer, meaning
 that users will only see changes made between ``topicB`` and ``topicA``.
 
+You can also use native revision syntax for your particular version control
+system. For example, with Git, this command is equivalent:
+
+.. code-block:: console
+
+    $ rbt post topicA..topicB
+
 
 Tracking Branches
 ~~~~~~~~~~~~~~~~~
diff --git a/docs/rbtools/rbt/configuration/repositories.rst b/docs/rbtools/rbt/configuration/repositories.rst
index 6f1cd3af9b3c97d4c5c1dd9449b28197f0ff73b5..945cd7c56ae04ee180aebcd5c530373ad000e62c 100644
--- a/docs/rbtools/rbt/configuration/repositories.rst
+++ b/docs/rbtools/rbt/configuration/repositories.rst
@@ -284,6 +284,26 @@ Example:
 This can also be provided by passing :option:`--include` to most commands.
 
 
+.. rbtconfig:: JJ_COMMITS_USE_GIT_SHA
+
+**Commands:** :rbtcommand:`rbt post`
+
+**Type:** Boolean
+
+**Default:** ``False``
+
+By default, the Jujutsu integration in RBTools will use Jujutsu change IDs for
+commits. Depending on your environment, this may not be desirable--for example,
+your Git server may reject any pushes for commits that have not been marked as
+"Ship it!" in Review Board. This can be changed to send the Git hash instead.
+
+Example:
+
+.. code-block:: python
+
+   JJ_COMMITS_USE_GIT_SHA = True
+
+
 .. rbtconfig:: LAND_DELETE_BRANCH
 
 LAND_DELETE_BRANCH
@@ -492,6 +512,7 @@ Valid repository types include:
 * ``clearcase``
 * ``cvs``
 * ``git``
+* ``jujutsu``
 * ``mercurial``
 * ``perforce``
 * ``plastic``
diff --git a/docs/rbtools/workflows/index.rst b/docs/rbtools/workflows/index.rst
index 1383f65147577836da3d2e57782b2d969337b7a0..8077313a185a51fa6c427bb3ce5ab5b6a7fe8942 100644
--- a/docs/rbtools/workflows/index.rst
+++ b/docs/rbtools/workflows/index.rst
@@ -13,6 +13,7 @@ RBTools Workflows
    sos
    git
    clearcase
+   jujutsu
    perforce
 
 
diff --git a/docs/rbtools/workflows/jujutsu.rst b/docs/rbtools/workflows/jujutsu.rst
new file mode 100644
index 0000000000000000000000000000000000000000..6932bf870b9fb7d58298de338c039bd5cbcb1198
--- /dev/null
+++ b/docs/rbtools/workflows/jujutsu.rst
@@ -0,0 +1,373 @@
+.. _rbtools-workflow-jujutsu:
+
+==========================
+Using RBTools with Jujutsu
+==========================
+
+Jujutsu_ is a version control system which works seamlessly with Git servers
+while providing new and innovative concepts and commands. Because of its
+interoperability, it's a powerful tool that can be integrated into individual
+developer workflows without requiring major organizational changes.
+
+.. note::
+
+    The Jujutsu support in Review Board and RBTools is client-side only, and
+    expects that your remote is a Git server. Jujutsu does have a "native"
+    backend, but as of now it is not usable. If and when the native backend
+    becomes useful, we will consider adding support for it.
+
+.. note::
+
+   Jujutsu is a young project, and is undergoing rapid changes. It's possible
+   that changes to the :command:`jj` command-line interface may break RBTools
+   integration.
+
+
+.. _Jujutsu: https://jj-vcs.github.io/jj/latest/
+
+
+.. _rbtools-workflow-jujutsu-configuration:
+
+Configuration
+=============
+
+Tracking bookmark
+-----------------
+
+RBTools will attempt to find your nearest tracking bookmark and use that as the
+base for posted changes. Depending on how your repository is laid out (for
+example, if you have multiple remotes), this may not find the correct upstream
+branch. You can override this in :file:`.reviewboardrc`:
+
+.. code-block:: python
+
+    TRACKING_BRANCH = 'main@origin'
+
+
+Commit IDs
+----------
+
+By default, the Jujutsu integration in RBTools will use Jujutsu change IDs for
+commits. Depending on your environment, this may not be desirable--for example,
+your Git server may reject any pushes for commits that have not been marked as
+"Ship it!" in Review Board. This can be changed to send the Git hash instead.
+
+.. code-block:: python
+
+   JJ_COMMITS_USE_GIT_SHA = True
+
+
+Overriding the repository config
+--------------------------------
+
+It's common for repositories to have a :file:`.reviewboardrc` committed to the
+source tree, with standard settings defined. If you're using Jujutsu on a
+project where everyone else is using Git, this file might have settings which
+need to be overridden.
+
+The :rbtconfig:`TREES` setting allows you to define overrides for configuration
+keys. For example, consider a repository with a :file:`.reviewboardrc` that
+contains:
+
+.. code-block:: python
+
+    REVIEWBOARD_URL = 'https://reviews.example.com/'
+    REPOSITORY_TYPE = 'git'
+    BRANCH = 'main'
+    TRACKING_BRANCH = 'origin/main'
+    LAND_DEST_BRANCH = 'main'
+
+This will obviously not work correctly if you're using Jujutsu on the client.
+You can create a :ref:`personal configuration <rbtools-reviewboardrc>` file
+(e.g. in :file:`$HOME/.reviewboardrc`) with the following to override the
+problematic settings:
+
+.. code-block:: python
+
+    TREES = {
+        '/home/user/src/my-jj-repo': {
+            'REPOSITORY_TYPE': 'jujutsu',
+            'TRACKING_BRANCH': 'main@origin',
+        },
+    }
+
+
+.. _rbtools-workflow-jujutsu-posting:
+
+Step 1: Posting Changes
+=======================
+
+When using the Jujutsu integration, you can use the `Revset Language`_ to
+specify which changes to include in your post.
+
+
+.. _Revset Language: https://jj-vcs.github.io/jj/latest/revsets/
+
+
+Posting all changes since the tracking bookmark
+-----------------------------------------------
+
+With no arguments, :command:`rbt post` will attempt to find your closest
+tracking bookmark and post all changes between that and your working copy.
+
+
+Posting a single change
+-----------------------
+
+If you want to post the diff for only a single change (or commit), you can pass
+in that commit as a single revision argument. This can be used with a specific
+change ID, or you can pass ``@`` to post the content of your working copy.
+
+.. code-block:: console
+
+    $ rbt post @
+
+    $ rbt post <change-id>
+
+    $ rbt post <bookmark-name>
+
+
+Posting a range of changes
+--------------------------
+
+You can post a range of commits by passing a revset that represents a range of
+commits, or passing two arguments representing the base and tip of the range
+you want to post.
+
+.. code-block:: console
+
+    $ rbt post release-branch..@
+
+
+Step 2: Update from reviewer feedback
+=====================================
+
+Got some reviewer feedback to incorporate into your change? Easy.
+
+1. Create a new change, or edit your existing change.
+
+2. Run :option:`rbt post -u` to update your review request.
+
+   This will try to locate the review request you posted to before, comparing
+   the summary and description with your change description. It will ask you if
+   it's not sure which one is correct.
+
+3. Update any information on the review request, if you want to.
+
+   We recommend describing the changes you've made, so reviewers know what
+   to look for. The field for this is on the green draft banner.
+
+4. Publish the new changes for review.
+
+
+Step 3: Land your change
+========================
+
+.. program:: rbt land
+
+Once you've gotten approval to land the change, it's time to use
+:ref:`rbt land <rbt-land>`. This will take your local change (or a review
+request ID using :option:`-r`, if landing another person's change) and:
+
+1. Validate that the change has been approved.
+2. Merge or squash the change into the target branch.
+3. Optionally push the change(es) upstream (:option:`--push`).
+
+You can choose a branch to land to by using :option:`--dest`. To
+configure a standard destination branch in your :ref:`rbtools-reviewboardrc`,
+set ``LAND_DEST_BRANCH = '<branchname>'``. Make sure this is a local branch,
+not a remote branch!
+
+:ref:`rbt land <rbt-land>` has a lot of :ref:`options <rbt-land-options>` you
+can play with. Because of the way Jujutsu handles merges (i.e. the lack of
+built-in fast-forward merges), you may want to use :option:`--squash`
+(``LAND_SQUASH = True``), if you like clean, linear commit histories.
+
+You can edit the commit message before creating the commit using
+:option:`--edit`.
+
+
+Putting it all together
+=======================
+
+Let's walk through an example. We'll start with a ``jj`` repository which is
+cloned from a Git upstream:
+
+.. code-block:: console
+
+    $ jj log
+    @  mkykvuvp me@example.com 2025-01-09 10:10:53 c4c38566
+    │  (empty) (no description set)
+    ◆  uwxxsykv colleague@example.com 2025-01-03 09:54:00 main 1ddfc59e
+    │  docs: Use "branch" consistently when talking about Git's branches
+    ~
+
+First let's make sure we have our configuration set correctly in
+:file:`.reviewboardrc`::
+
+    REPOSITORY_TYPE = 'jujutsu'
+    TRACKING_BRANCH = 'main@origin'
+    LAND_DEST_BRANCH = 'main'
+
+
+We do some work, creating a couple changes:
+
+.. code-block:: console
+
+    $ vim foo.py
+    $ jj commit -m "Change 1"
+    $ vim bar.py
+    $ jj commit -m "Change 2"
+
+
+Our log now looks like this:
+
+.. code-block:: console
+
+    $ jj log
+    @  wrsqkluy me@example.com 2025-02-07 09:27:17 81ba1f2c
+    │  (empty) (no description set)
+    ○  wwxtrsxp me@example.com 2025-02-07 09:13:23 e79f595f
+    │  Change 2
+    ○  pxolvpnn me@example.com 2025-02-07 08:29:46 8be9e5ff
+    │  Change 1
+    ◆  wulynnnz colleague@example.com 2025-01-24 09:16:35 main 918a5d23
+    │  Fix unit tests for main module.
+    ~
+
+
+At this point, we need to make a decision about what and how we want to ask for
+review. If all our changes were fundamentally part of the same thing, we might
+collapse them in to a single review request. If they're separate, we'd want to
+post them individually.
+
+Let's say for this example that these changes are for two different things.
+We're still iterating on our second change, but we think the first one is
+ready:
+
+.. code-block:: console
+
+    $ rbt post p
+    Review Request #1002 posted.
+
+    https://reviewboard.example.com/r/1002/
+    https://reviewboard.example.com/r/1002/diff/
+
+
+Now we can do some more work and post our second change for review as well:
+
+.. code-block:: console
+
+    $ vim bar.py
+
+    $ jj absorb bar.py
+    Absorbed changes into these revisions:
+      wwxtrsxp e79f595f Change 2
+    Rebased 1 descendant commits.
+    Working copy now at: wrsqkluy a2925006 (empty) (no description set)
+    Parent commit      : wwtrsxpu 351657f9 Change 2
+
+    $ rbt post ww
+    Review Request #1007 posted.
+
+    https://reviewboard.example.com/r/1007/
+    https://reviewboard.example.com/r/1007/diff/
+
+
+Say we've now received some feedback on our first change, and we want to make
+some changes. We'll implement the requested changes, squash them into that
+first change, and then update our review request.
+
+.. code-block:: console
+
+    $ vim foo.py
+
+    $ jj squash -t p
+    Rebased 2 descendant commits.
+    Working copy now at: zzovrlyy 51513a22 (empty) (no description set)
+    Parent commit      : wwtrsxpu e04b53f7 Change 2
+
+    $ rbt post -u p
+    Review Request #1002 posted.
+
+    https://reviewboard.example.com/r/1002/
+    https://reviewboard.example.com/r/1002/diff/
+
+.. tip::
+
+    You can update (:option:`-u <rbt post -u>`, describe the changes
+    (:option:`-m <rbt post -m>`), and publish (:option:`-p <rbt post -p>`), all
+    in the same step:
+
+    .. code-block:: console
+
+        $ rbt post -u -p -m "Fixed a broken link." p
+
+
+Hey, we got a Ship It! for that first review request. Let's land it.
+
+We have a choice between doing a merge or a squash. By default,
+:rbtcommand:`rbt land` will do a merge. This involves creating a new merge
+change, whether or not it is actually necessary. For example:
+
+.. code-block:: console
+
+    $ rbt land p
+    Land Review Request #1002: "Change 1"?  [Yes/No]: y
+    Merging branch "p" into "main".
+    Review request 14305 has landed on "main".
+
+    $ jj log
+    @  wrsqkluy me@example.com 2025-02-07 09:27:17 81ba1f2c
+    │  (empty) (no description set)
+    ○  wwxtrsxp me@example.com 2025-02-07 09:13:23 e79f595f
+    │  Change 2
+    │ ○  ntyoqzrk me@example.com 2025-02-07 08:34:30 main* c4d581cd
+    ╭─┤  (empty) Change 1
+    ○ │  pxolvpnn me@example.com 2025-02-07 08:29:46 8be9e5ff
+    ├─╯  Change 1
+    ◆  wulynnnz colleague@example.com 2025-01-24 09:16:35 main@origin 918a5d23
+    │  Fix unit tests for main module.
+    ~
+
+    $ jj git push -b main
+    $ jj rebase -d main
+
+
+In most cases, having these merge commits is ugly. When we're working
+with branches that are just a single commit, it's not adding any value.
+Instead, we can use a squash workflow to keep our history linear:
+
+.. code-block:: console
+
+    $ rbt land --squash m
+    Land Review Request #1002: "Change 1"?  [Yes/No]: y
+    Merging branch "p" into "main".
+    Review request 14305 has landed on "main".
+
+    $ jj log
+    @  upxolvpnn me@example.com 2025-01-30 19:22:14 main 51187c77
+    │  Change 1
+    │ ○  tnqtqtwu me@example.com 2025-01-29 09:27:17 be927f5c
+    ├─╯  Change 2
+    ◆  uwxxsykv colleague@example.com 2025-01-03 09:54:00 1ddfc59e
+    │  Edit some code
+    ~
+
+    @  wrsqkluy me@example.com 2025-02-07 09:27:17 81ba1f2c
+    │  (empty) (no description set)
+    ○  wwxtrsxp me@example.com 2025-02-07 08:37:53 72aa68eb
+    │  Change 2
+    │ ○  wktnlpxw me@example.com 2025-02-07 08:37:53 main* 51c29bc5
+    ├─╯  Change 1
+    ◆  wulynnnz colleague@example.com 2025-01-24 09:16:35 main@origin 918a5d23
+    │  Fix unit tests for main module.
+    ~
+
+    $ jj git push -b main
+    $ jj rebase -d main
+
+.. tip::
+
+    You can configure :rbtcommand:`rbt land` to always use squash by setting
+    ``LAND_SQUASH = True`` in your :file:`.reviewboardrc`.
diff --git a/rbtools/clients/base/registry.py b/rbtools/clients/base/registry.py
index 8a10c6e6d9abbddf6b1e3ceaf128a890c59343f6..ba9e24c75fce38a7c3a6e90b1cf7b943885027a5 100644
--- a/rbtools/clients/base/registry.py
+++ b/rbtools/clients/base/registry.py
@@ -215,6 +215,7 @@ class SCMClientRegistry:
             ('rbtools.clients.clearcase', 'ClearCaseClient'),
             ('rbtools.clients.cvs', 'CVSClient'),
             ('rbtools.clients.git', 'GitClient'),
+            ('rbtools.clients.jujutsu', 'JujutsuClient'),
             ('rbtools.clients.mercurial', 'MercurialClient'),
             ('rbtools.clients.perforce', 'PerforceClient'),
             ('rbtools.clients.plastic', 'PlasticClient'),
diff --git a/rbtools/clients/git.py b/rbtools/clients/git.py
index 8f963c339c24811a3f710370877a96adca979bbf..512dc7303f6429ce0da21583d0090501dd3db461 100644
--- a/rbtools/clients/git.py
+++ b/rbtools/clients/git.py
@@ -2265,3 +2265,14 @@ class GitClient(BaseSCMClient):
                 .read())
         except Exception as e:
             raise SCMError(e)
+
+    def supports_empty_files(self) -> bool:
+        """Return whether the RB server supports added/deleted empty files.
+
+        Returns:
+            bool:
+            ``True`` if the Review Board server supports showing empty files.
+        """
+        return (self.capabilities is not None and
+                self.capabilities.has_capability('scmtools', 'git',
+                                                 'empty_files'))
diff --git a/rbtools/clients/jujutsu.py b/rbtools/clients/jujutsu.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbd721f4308a4b8f1d05a6ffaca72483f3e963d9
--- /dev/null
+++ b/rbtools/clients/jujutsu.py
@@ -0,0 +1,1402 @@
+"""Client implementation for Jujutsu.
+
+Version Added:
+    6.0
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import subprocess
+from gettext import gettext as _
+from typing import TYPE_CHECKING
+
+from rbtools.clients.base.repository import RepositoryInfo
+from rbtools.clients.base.scmclient import (
+    BaseSCMClient,
+    SCMClientCommitHistoryItem,
+    SCMClientDiffResult,
+    SCMClientPatcher,
+    SCMClientRevisionSpec,
+)
+from rbtools.clients.errors import (
+    AmendError,
+    CreateCommitError,
+    MergeError,
+    PushError,
+    SCMError,
+    SCMClientDependencyError,
+    TooManyRevisionsError,
+)
+from rbtools.utils.console import edit_text
+from rbtools.utils.diffs import (
+    normalize_patterns,
+    remove_filenames_matching_patterns,
+)
+from rbtools.utils.checks import check_install
+from rbtools.utils.errors import EditorError
+from rbtools.utils.process import RunProcessError, run_process
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable, Iterator, Sequence
+
+    from rbtools.diffs.patches import Patch, PatchAuthor, PatchResult
+
+
+logger = logging.getLogger(__name__)
+
+
+class JujutsuPatcher(SCMClientPatcher['JujutsuClient']):
+    """A patcher that applies patches to a Jujutsu tree.
+
+    Version Added:
+        6.0
+    """
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: Whether the current change was empty when the operation started.
+    _was_current_empty: bool
+
+    def get_default_prefix_level(
+        self,
+        *,
+        patch: Patch,
+    ) -> int:
+        """Return the default path prefix strip level for a patch.
+
+        This adds one to the prefix level to handle Git-format "a/" and "b/"
+        paths.
+
+        Args:
+            patch (rbtools.diffs.patches.Patch):
+                The path to generate a default prefix strip level for.
+
+        Returns:
+            int:
+            The prefix strip level.
+        """
+        p = super().get_default_prefix_level(patch=patch)
+
+        if p is not None:
+            return p + 1
+        else:
+            return 1
+
+    def patch(self) -> Iterator[PatchResult]:
+        """Apply patches to the tree.
+
+        Yields:
+            rbtools.diffs.patches.PatchResult:
+            The result of each patch application, whether the patch applied
+            successfully or with normal patch failures.
+
+        Raises:
+            rbtools.diffs.errors.ApplyPatchError:
+                There was an error attempting to apply a patch.
+
+                This won't be raised simply for conflicts or normal patch
+                failures. It may be raised for errors encountered during
+                the patching process.
+        """
+        # Check if there's any data in the working copy first.
+        has_info = (
+            (
+                run_process(['jj', 'log', '-r', '@', '--no-graph', '-T',
+                             'empty'])
+                .stdout
+                .read()
+                .strip()
+            ) != 'true')
+
+        if not has_info:
+            # No diff, but now check if there's a change description.
+            has_info = (
+                (
+                    run_process(['jj', 'log', '-r', '@', '--no-graph', '-T',
+                                 'description'])
+                    .stdout
+                    .read()
+                    .strip()
+                ) != '')
+
+        if has_info:
+            run_process(['jj', 'new'])
+
+        yield from super().patch()
+
+    def create_commit(
+        self,
+        *,
+        patch_result: PatchResult,
+        run_commit_editor: bool,
+    ) -> None:
+        """Create a commit based on a patch result.
+
+        Args:
+            patch_result (rbtools.diffs.patches.PatchResult):
+                The patch result containing the patch/patches to commit.
+
+            run_commit_editor (bool):
+                Whether to run the configured commit editor to alter the
+                commit message.
+
+        Raises:
+            rbtools.diffs.errors.ApplyPatchResult:
+                There was an error attempting to commit the patch.
+        """
+        patch = patch_result.patch
+        assert patch
+
+        author = patch.author
+        message = patch.message
+
+        assert author
+        assert message
+
+        self.scmclient.create_commit(author=author,
+                                     message=message,
+                                     run_editor=self.run_commit_editor,
+                                     create_new_change=True)
+
+
+class JujutsuClient(BaseSCMClient):
+    """Client implementation for Jujutsu.
+
+    Version Added:
+        6.0
+    """
+
+    scmclient_id = 'jujutsu'
+    name = 'Jujutsu'
+    patcher_cls = JujutsuPatcher
+    server_tool_names = 'Git'
+    server_tool_ids = ['git']
+
+    supports_commit_history = True
+    supports_diff_exclude_patterns = True
+    supports_parent_diffs = True
+
+    can_amend_commit = True
+    can_bookmark = True
+    can_delete_branch = False
+    can_get_file_content = True
+    can_merge = True
+    can_push_upstream = True
+    can_squash_merges = True
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: The path to the Git object storage within the .jj directory.
+    _git_store: str
+
+    #: Whether multiple remotes were found.
+    _has_multiple_remotes: (bool | None) = None
+
+    #: The path to the top level of the repository.
+    _local_path: str | None
+
+    def __init__(self, **kwargs) -> None:
+        """Initialize the client.
+
+        Args:
+            **kwargs (dict):
+                Keyword arguments to pass through to the superclass.
+        """
+        super().__init__(**kwargs)
+
+        self._local_path = None
+
+    def check_dependencies(self) -> None:
+        """Check whether all dependencies for the client are available.
+
+        This checks that both the ``git`` and ``jj`` commands are available.
+
+        Raises:
+            rbtools.clients.errors.SCMClientDependencyError:
+                The required command-line tools were not available.
+        """
+        missing_exes: SCMClientDependencyError.MissingList = []
+
+        if not check_install(['git', '--help']):
+            missing_exes.append('git')
+
+        if not check_install(['jj', '--help']):
+            missing_exes.append('jj')
+
+        if missing_exes:
+            raise SCMClientDependencyError(missing_exes=missing_exes)
+
+    def get_local_path(self) -> str | None:
+        """Return the local path to the working tree.
+
+        Returns:
+            str:
+            The filesystem path of the repository on the client system.
+        """
+        if self._local_path is None:
+            try:
+                jj_root = (
+                    run_process(['jj', 'root'])
+                    .stdout
+                    .read()
+                    .strip()
+                )
+                store_base = os.path.join(jj_root, '.jj', 'repo', 'store')
+                target = os.path.join(store_base, 'git_target')
+
+                if not os.path.exists(target):
+                    logger.warning('Jujutsu repository root found at %s, but '
+                                   'git_target file was not.',
+                                   jj_root)
+                    return None
+
+                with open(target) as fp:
+                    relpath = fp.read().strip()
+                    git_store = os.path.normpath(
+                        os.path.join(store_base, relpath))
+
+                if not os.path.exists(git_store):
+                    logger.warning('Jujutsu repository root found at %s, but '
+                                   'Git store was not.',
+                                   jj_root)
+                    return None
+
+                self._local_path = jj_root
+                self._git_store = git_store
+            except RunProcessError:
+                pass
+
+        return self._local_path
+
+    def get_repository_info(self) -> RepositoryInfo | None:
+        """Return repository information for the current working tree.
+
+        Returns:
+            rbtools.clients.base.repository.RepositoryInfo:
+            The repository info structure.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred trying to find the repository info.
+        """
+        local_path = self.get_local_path()
+
+        if not local_path:
+            return None
+
+        repository_url = getattr(self.options, 'repository_url', None)
+        url: (str | None) = None
+
+        if repository_url:
+            url = repository_url
+        else:
+            try:
+                remotes = self._get_remotes()
+                self._has_multiple_remotes = (len(remotes) > 1)
+
+                if not self._has_multiple_remotes:
+                    url = remotes[0].split()[1]
+                else:
+                    parent_bookmark = self._get_parent_bookmark()
+
+                    if '@' in parent_bookmark:
+                        parent_remote = parent_bookmark.split('@', 1)[1]
+
+                        for line in remotes:
+                            try:
+                                line_name, line_url = line.split(' ', 1)
+                            except Exception:
+                                continue
+
+                            if line_name == parent_remote:
+                                url = line_url
+                                break
+            except RunProcessError as e:
+                raise SCMError(
+                    _('Could not determine Git remote for Jujutsu '
+                      'repository: {error}')
+                    .format(error=str(e)))
+
+        if url:
+            return RepositoryInfo(path=url,
+                                  base_path='',
+                                  local_path=local_path)
+        else:
+            return None
+
+    def parse_revision_spec(
+        self,
+        revisions: (Sequence[str] | None) = None,
+    ) -> SCMClientRevisionSpec:
+        """Parse the given revision spec.
+
+        This will parse revision arguments in order to generate the diffs to
+        upload to Review Board (or print). The diff for review will include the
+        changes in (base, tip], and the parent diff (if necessary) will include
+        (parent_base, base].
+
+        If a single revision is passed in, this will return the parent of that
+        revision for "base" and the passed-in revision for "tip".
+
+        If zero revisions are passed in, this will return the current HEAD as
+        "tip" and the upstream bookmark as "base", taking into account parent
+        branches (bookmarks) explicitly specified via :option:`--parent`.
+
+        Args:
+            revisions (list of str, optional):
+                A list of revisions as specified by the user.
+
+        Raises:
+            rbtools.clients.errors.InvalidRevisionSpecError:
+                The given revisions could not be parsed.
+
+            rbtools.clients.errors.SCMError:
+                There was an error retrieving information from Git.
+
+            rbtools.clients.errors.TooManyRevisionsError:
+                The specified revisions list contained too many revisions.
+        """
+        if revisions is None:
+            revisions = []
+
+        n_revs = len(revisions)
+        result: SCMClientRevisionSpec
+
+        tip: str
+        base: str
+        parent_bookmark: str
+        parent_base: str
+
+        if n_revs == 0:
+            # No revisions were passed in. Start with @, and find the tracking
+            # branch automatically.
+            tip = self._get_change_id('@')
+            parent_bookmark = self._get_parent_bookmark()
+            base = self._get_change_id(parent_bookmark)
+
+            result = {
+                'base': base,
+                'tip': tip,
+                'commit_id': tip,
+            }
+        elif n_revs == 1:
+            # A single revision was passed in. This could be an actual single
+            # revision, or it could be a revset that represents a range.
+            changes = self._get_change_ids(revisions[0])
+            n_changes = len(changes)
+
+            if n_changes == 1:
+                # The revset returned a single change. Use that as the tip and
+                # find its parent as the base.
+                tip = changes[0]
+                base = self._get_change_id(f'{tip}-')
+                parent_bookmark = self._get_parent_bookmark(base)
+
+                result = {
+                    'base': base,
+                    'tip': tip,
+                    'commit_id': tip,
+                }
+            else:
+                # The revset returned multiple changes. Use the top and bottom
+                # of that range.
+                tip = changes[0]
+                base = changes[-1]
+                parent_bookmark = self._get_parent_bookmark(base)
+
+                result = {
+                    'base': base,
+                    'commit_id': tip,
+                    'tip': tip,
+                }
+        elif n_revs == 2:
+            base = self._get_change_id(revisions[0])
+            tip = self._get_change_id(revisions[1])
+            parent_bookmark = self._get_parent_bookmark(base)
+
+            result = {
+                'base': base,
+                'commit_id': tip,
+                'tip': tip,
+            }
+        else:
+            raise TooManyRevisionsError
+
+        if '@' in parent_bookmark:
+            parent_base = self._get_fork_point(tip, parent_bookmark)
+        else:
+            remote_bookmark = self._get_remote_bookmark(base)
+            parent_base = self._get_fork_point(tip, remote_bookmark)
+
+        # If the most recent upstream commit is not the same as our revision
+        # range base, include a parent base in the result.
+        if base != parent_base:
+            result['parent_base'] = parent_base
+
+        return result
+
+    def diff(
+        self,
+        revisions: SCMClientRevisionSpec,
+        *,
+        include_files: (Sequence[str] | None) = None,
+        exclude_patterns: (Sequence[str] | None) = None,
+        no_renames: bool = False,
+        repository_info: (RepositoryInfo | None) = None,
+        with_parent_diff: bool = True,
+        **kwargs,
+    ) -> SCMClientDiffResult:
+        """Perform a diff using the given revisions.
+
+        Args:
+            revisions (dict):
+                A dictionary of revisions, as returned by
+                :py:meth:`parse_revision_spec`.
+
+            include_files (list of str, optional):
+                A list of files to whitelist during the diff generation.
+
+            exclude_patterns (list of str, optional):
+                A list of shell-style glob patterns to blacklist during diff
+                generation.
+
+            no_renames (bool, optional):
+                Whether to avoid rename detection.
+
+            repository_info (rbtools.clients.base.repository.RepositoryInfo,
+                             optional):
+                The repository info.
+
+            with_parent_diff (bool, optional):
+                Whether or not to compute a parent diff.
+
+            **kwargs (dict, unused):
+                Unused keyword arguments.
+
+        Returns:
+            dict:
+            A dictionary containing keys documented in
+            :py:class:`~rbtools.clients.base.scmclient.SCMClientDiffResult`.
+        """
+        if include_files is None:
+            include_files = []
+
+        if exclude_patterns is None:
+            exclude_patterns = []
+        else:
+            assert self._local_path is not None
+            exclude_patterns = normalize_patterns(
+                patterns=exclude_patterns,
+                base_dir=self._local_path,
+                cwd=os.getcwd())
+
+        base = revisions['base']
+        tip = revisions['tip']
+
+        assert isinstance(base, str)
+        assert isinstance(tip, str)
+
+        diff = self._do_diff(
+            base=base,
+            tip=tip,
+            include_files=include_files,
+            exclude_patterns=exclude_patterns)
+
+        if 'parent_base' in revisions and with_parent_diff:
+            parent_base = revisions['parent_base']
+            assert isinstance(parent_base, str)
+
+            parent_diff = self._do_diff(
+                base=parent_base,
+                tip=base,
+                include_files=include_files,
+                exclude_patterns=exclude_patterns)
+            base_commit_id = parent_base
+        else:
+            parent_diff = None
+            base_commit_id = base
+
+        return {
+            'base_commit_id': base_commit_id,
+            'commit_id': revisions.get('commit_id'),
+            'diff': diff,
+            'parent_diff': parent_diff,
+        }
+
+    def get_raw_commit_message(
+        self,
+        revisions: SCMClientRevisionSpec,
+    ) -> str:
+        """Extract the commit message based on the provided revision range.
+
+        Args:
+            revisions (dict):
+                A dictionary containing ``base`` and ``tip`` keys.
+
+        Returns:
+            str:
+            The commit messages of all commits between (base, tip].
+        """
+        base = revisions['base']
+        tip = revisions['tip']
+
+        assert isinstance(base, str)
+        assert isinstance(tip, str)
+
+        return (
+            run_process(['jj', 'log', '-r', f'{base}..{tip}',
+                         '--reversed', '-T', 'description', '--no-graph'])
+            .stdout
+            .read()
+            .strip()
+        )
+
+    def get_commit_history(
+        self,
+        revisions: SCMClientRevisionSpec,
+    ) -> Sequence[SCMClientCommitHistoryItem] | None:
+        """Return the commit history specified by the revisions.
+
+        Args:
+            revisions (dict):
+                A dictionary of revisions to generate history for, as returned
+                by :py:meth:`parse_revision_spec`.
+
+        Returns:
+            list of dict:
+            The list of history entries, in order.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                The history is non-linear or there is a commit with no parents.
+        """
+        base = revisions['base']
+        tip = revisions['tip']
+
+        assert isinstance(base, str)
+        assert isinstance(tip, str)
+
+        log_fields = {
+            'commit_id': 'change_id',
+            'parent_id': 'parents.map(|c| c.change_id())',
+            'author_name': 'author.name()',
+            'author_email': 'author.email()',
+            'author_date': 'author.timestamp().format("%+")',
+            'committer_name': 'committer.name()',
+            'committer_email': 'committer.email()',
+            'committer_date': 'committer.timestamp().format("%+")',
+            'commit_message': 'description',
+        }
+
+        if self.config.get('JJ_COMMITS_USE_GIT_SHA', False):
+            log_fields['commit_id'] = 'commit_id'
+
+        log_format = ' ++ "\x1f" ++ '.join(log_fields.values())
+        log_entries = (
+            run_process(['jj', 'log', '-r', f'{base}..{tip}', '--reversed',
+                         '-T', f'{log_format} ++ "\x1e"', '--no-graph'])
+            .stdout
+            .read()
+            .split("\x1e")
+        )
+
+        history: list[SCMClientCommitHistoryItem] = []
+        field_names = log_fields.keys()
+
+        for log_entry in log_entries:
+            if not log_entry:
+                break
+
+            fields = log_entry.split("\x1f")
+            entry = SCMClientCommitHistoryItem(
+                **dict(zip(field_names, fields)))
+
+            parent_id = entry['parent_id']
+            assert isinstance(parent_id, str)
+
+            parents = parent_id.split()
+
+            if len(parents) > 1:
+                raise SCMError(_(
+                    'The Jujutsu SCMClient only supports posting commit '
+                    'histories that are entirely linear.',
+                ))
+            elif len(parents) == 0:
+                raise SCMError(_(
+                    'The Jujutsu SCMClient only supports posting commits '
+                    'that have exactly one parent.',
+                ))
+
+            message = entry['commit_message']
+            assert isinstance(message, str)
+
+            message = message.strip()
+
+            if not message:
+                # It's not unusual in Jujutsu for the working-copy commit (or
+                # even parent changes) to not yet have a commit message.
+                message = 'No description set'
+
+            entry['commit_message'] = message
+
+            history.append(entry)
+
+        return history
+
+    def get_file_content(
+        self,
+        *,
+        filename: str,
+        revision: str,
+    ) -> bytes:
+        """Return the contents of a file at a given revision.
+
+        Args:
+            filename (str):
+                The file to fetch.
+
+            revision (str):
+                The revision of the file to get.
+
+        Returns:
+            bytes:
+            The read file.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred trying to get the file content.
+        """
+        try:
+            return (
+                run_process(['git', 'cat-file', 'blob', revision],
+                            cwd=self._git_store)
+                .stdout_bytes
+                .read()
+            )
+        except RunProcessError as e:
+            raise SCMError(
+                _('Unable to get file content for {filename} (revision '
+                  '{revision}): {error}')
+                .format(filename=filename, revision=revision, error=str(e)))
+
+    def get_file_size(
+        self,
+        *,
+        filename: str,
+        revision: str,
+    ) -> int:
+        """Return the size of a file at a given revision.
+
+        Args:
+            filename (str):
+                The file to check.
+
+            revision (str):
+                The revision of the file to check.
+
+        Returns:
+            int:
+            The size of the file, in bytes.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred trying to get the size of the file.
+        """
+        try:
+            return int(
+                run_process(['git', 'cat-file', '-s', revision],
+                            cwd=self._git_store)
+                .stdout
+                .read())
+        except RunProcessError as e:
+            raise SCMError(
+                _('Unable to get file size for {filename} (revision '
+                  '{revision}): {error}')
+                .format(
+                    filename=filename,
+                    revision=revision,
+                    error=str(e),
+                )
+            )
+
+    def get_current_bookmark(self) -> str:
+        """Return the current bookmark of this repository.
+
+        Returns:
+            str:
+            The name of the bookmark at the current commit.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred trying to get the current bookmark.
+        """
+        try:
+            return (
+                run_process(['jj', 'bookmark', 'list', '-r', '@', '-T',
+                             'name ++ "\n"'])
+                .stdout
+                .read()
+                .split('\n')[0]
+            )
+        except RunProcessError as e:
+            raise SCMError(
+                _('Unable to get the current bookmark: {error}')
+                .format(error=str(e)))
+
+    def supports_empty_files(self) -> bool:
+        """Return whether the server supports added/deleted empty files.
+
+        Returns:
+            bool:
+            ``True`` if the Review Board server supports added or deleted empty
+            files.
+        """
+        return (self.capabilities is not None and
+                self.capabilities.has_capability('scmtools', 'git',
+                                                 'empty_files'))
+
+    def amend_commit_description(
+        self,
+        message: str,
+        revisions: (SCMClientRevisionSpec | None) = None,
+    ) -> None:
+        """Update a commit message to the given string.
+
+        Args:
+            message (str):
+                The commit message to use when amending the commit.
+
+            revisions (dict, optional):
+                A dictionary of revisions, as returned by
+                :py:meth:`parse_revision_spec`. This provides compatibility
+                with SCMs that allow modifications of multiple changesets at
+                any given time, and will amend the change referenced by the
+                ``tip`` key.
+
+        Raises:
+            rbtools.clients.errors.AmendError:
+                The amend operation failed.
+        """
+        command = ['jj', 'describe', '--quiet', '-m', message]
+
+        if revisions and revisions['tip']:
+            tip = revisions['tip']
+            assert isinstance(tip, str)
+
+            command.append(tip)
+
+        try:
+            run_process(command)
+        except RunProcessError as e:
+            raise AmendError(str(e))
+
+    def create_commit(
+        self,
+        *,
+        message: str,
+        author: PatchAuthor,
+        run_editor: bool,
+        files: (Sequence[str] | None) = None,
+        all_files: bool = False,
+        create_new_change: bool = True,
+    ) -> None:
+        """Create a commit based on the provided message and author.
+
+        Args:
+            message (str):
+                The commit message to use.
+
+            author (rbtools.diffs.patches.PatchAuthor):
+                The author of the commit.
+
+            run_editor (bool):
+                Whether to run the user's editor on the commit message before
+                committing.
+
+            files (list of str, optional):
+                The list of filenames to commit.
+
+            all_files (bool, optional):
+                Whether to commit all changed files, ignoring the ``files``
+                argument.
+
+            create_new_change (bool, optional):
+                Whether to create a new change after setting the description.
+
+        Raises:
+            rbtools.clients.errors.CreateCommitError:
+                The commit message could not be created. It may have been
+                aborted by the user.
+        """
+        if files:
+            raise CreateCommitError(_(
+                'The Jujutsu backend does not support creating commits with a '
+                'subset of files.',
+            ))
+
+        if run_editor:
+            try:
+                modified_message = edit_text(message,
+                                             filename='COMMIT_EDITMSG')
+            except EditorError as e:
+                raise CreateCommitError(str(e))
+        else:
+            modified_message = message
+
+        if not modified_message.strip():
+            raise CreateCommitError(_(
+                "A commit message wasn't provided. The patched files are in "
+                "your working copy. You may run `jj describe` to provide a "
+                "change description.",
+            ))
+
+        cmd = ['jj', 'describe', '-m', modified_message]
+
+        try:
+            cmd += ['--author', f'{author.full_name} <{author.email}>']
+        except AttributeError:
+            # Users who have marked their profile as private won't include the
+            # full name or email fields in the API payload. Just commit as the
+            # user running RBTools.
+            logger.warning('The author has marked their Review Board profile '
+                           'information as private. Committing without '
+                           'author attribution.')
+
+        try:
+            run_process(cmd)
+
+            if create_new_change:
+                run_process(['jj', 'new'])
+        except RunProcessError as e:
+            raise CreateCommitError(str(e))
+
+    def merge(
+        self,
+        *,
+        target: str,
+        destination: str,
+        message: str,
+        author: PatchAuthor,
+        squash: bool = False,
+        run_editor: bool = False,
+        close_branch: bool = True,
+    ) -> None:
+        """Merge the target branch with destination branch.
+
+        Args:
+            target (str):
+                The name of the branch to merge.
+
+            destination (str):
+                The name of the branch to merge into.
+
+            message (str):
+                The commit message to use.
+
+            author (rbtools.diffs.patches.PatchAuthor):
+                The author of the commit.
+
+            squash (bool, optional):
+                Whether to squash the commits or do a plain merge.
+
+            run_editor (bool, optional):
+                Whether to run the user's editor on the commit message before
+                committing.
+
+            close_branch (bool, optional):
+                Whether to close/delete the merged branch.
+
+        Raises:
+            rbtools.clients.errors.MergeError:
+                An error occurred while merging the branch.
+        """
+        current_change = self._get_change_id('@')
+
+        if target == '@':
+            target = current_change
+
+        if run_editor:
+            try:
+                modified_message = edit_text(message,
+                                             filename='COMMIT_EDITMSG')
+            except EditorError as e:
+                raise MergeError(str(e))
+        else:
+            modified_message = message
+
+        if squash:
+            try:
+                run_process(['jj', 'new', '-m', modified_message, destination])
+
+                squash_cmd = ['jj', 'squash', '-u', '--from', target]
+
+                if not close_branch:
+                    squash_cmd.append('-k')
+
+                run_process(['jj', 'squash', '--from', target, '-u'])
+            except RunProcessError as e:
+                raise MergeError(
+                    _('Unable to create new squashed commit\n{output}')
+                    .format(output=e.result.stdout.read()))
+        else:
+            try:
+                run_process(['jj', 'new', '-m', modified_message, destination,
+                             target])
+            except RunProcessError as e:
+                raise MergeError(
+                    _('Unable to create new merge commit\n{output}')
+                    .format(output=e.result.stdout.read()))
+
+        try:
+            run_process(['jj', 'bookmark', 'move', destination])
+        except RunProcessError as e:
+            raise MergeError(
+                _('Unable to move boorkmark\n{output}')
+                .format(output=e.result.stdout.read()))
+
+        if target != current_change:
+            # Try to switch back to the original current change. It's possible
+            # that this no longer exists if it was squashed.
+            try:
+                run_process(['jj', 'edit', current_change])
+            except RunProcessError as e:
+                if not squash:
+                    logger.debug(
+                        'Failed to switch back to original change %s: %s',
+                        current_change, e)
+
+    def push_upstream(
+        self,
+        remote_branch: str,
+    ) -> None:
+        """Push the current branch to upstream.
+
+        Args:
+            remote_branch (str):
+                The name of the branch to push to.
+
+        Raises:
+            rbtools.client.errors.PushError:
+                The branch was unable to be pushed.
+        """
+        try:
+            run_process(['jj', 'git', 'push', '-b', remote_branch])
+        except RunProcessError as e:
+            raise PushError(str(e))
+
+    def has_pending_changes(self) -> bool:
+        """Check if there are changes in the working copy.
+
+        Returns:
+            bool:
+            ``False``, always.
+
+            For most SCM implementations, a ``True`` return value indicates
+            that there are pending changes in the working copy, and RBTools
+            will error out.
+
+            In Jujutsu, the "working copy" is a real commit, and we don't mind
+            if it has content because that doesn't block us from doing any
+            operations. Our implementation of patch/merge/etc. are designed to
+            handle a non-empty working copy commit and create a new change
+            before applying anything.
+        """
+        return False
+
+    def _do_diff(
+        self,
+        *,
+        base: str,
+        tip: str,
+        include_files: Sequence[str],
+        exclude_patterns: Sequence[str],
+    ) -> bytes:
+        """Perform a diff between two revisions.
+
+        Args:
+            base (str):
+                The base revision for the diff.
+
+            tip (str):
+                The tip revision for the diff.
+
+            include_files (list of str):
+                A list of files to whitelist during the diff generation.
+
+            exclude_patterns (list of str):
+                A list of shell-style glob patterns to blacklist during diff
+                generation.
+
+        Returns:
+            bytes:
+            The diff output.
+
+        Raises:
+            rbtools.utils.process.RunProcessError:
+                An error occurred attempting to run a :command:`jj` command.
+        """
+        if exclude_patterns:
+            assert self._local_path is not None
+
+            changed_files = (
+                run_process(['jj', 'diff', '--from', base, '--to', tip,
+                             '--name-only'])
+                .stdout
+                .read()
+            ).splitlines()
+
+            changed_files = remove_filenames_matching_patterns(
+                filenames=changed_files, patterns=exclude_patterns,
+                base_dir=os.getcwd())
+
+            diff_lines: list[bytes] = []
+
+            for filename in changed_files:
+                lines = (
+                    run_process(['jj', 'diff', '--git', '--from', base,
+                                 '--to', tip, '--', filename])
+                    .stdout_bytes
+                    .readlines()
+                )
+
+                if not lines:
+                    logger.error(
+                        'Could not get diff for all files (jj diff failed for '
+                        '%s). Refusing to return a partial diff',
+                        filename)
+                    diff_lines = []
+                    break
+
+                diff_lines += lines
+        else:
+            diff_cmd = ['jj', 'diff', '--git', '--from', base, '--to', tip]
+
+            if include_files:
+                diff_cmd += ['--', *include_files]
+
+            diff_lines = (
+                run_process(diff_cmd,
+                            ignore_errors=True,
+                            log_debug_output_on_error=False)
+                .stdout_bytes
+                .readlines()
+            )
+
+        return b''.join(self._expand_short_git_indexes(diff_lines))
+
+    def _get_parent_bookmark(
+        self,
+        tip: str = '@',
+    ) -> str:
+        """Return the parent bookmark.
+
+        Args:
+            tip (str, optional):
+                The change to find the parent bookmark for.
+
+        Returns:
+            str:
+            The name of the current parent bookmark.
+        """
+        return (getattr(self.options, 'parent_branch', None) or
+                getattr(self.options, 'tracking', None) or
+                self._get_remote_bookmark(tip))
+
+    def _get_remote_bookmark(
+        self,
+        tip: str = '@',
+    ) -> str:
+        """Return the closest remote bookmark.
+
+        Args:
+            tip (str, optional):
+                A revset for the tip change to find the closest bookmark for.
+
+        Returns:
+            str:
+            The name of the closest remote bookmark.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred attempting to get the bookmark.
+        """
+        if self._has_multiple_remotes is None:
+            remotes = self._get_remotes()
+            self._has_multiple_remotes = (len(remotes) > 1)
+
+        if self._has_multiple_remotes:
+            logger.warning(
+                'There are multiple Git remotes are configured in your jj '
+                'repository. Attempting to use the most recent remote '
+                'bookmark, but that may be on a remote which is not '
+                'connected to your Review Board server. We recommend '
+                'setting TRACKING_BRANCH in your .reviewboardrc file to '
+                'the upstream remote branch you want to use '
+                '(e.g. main@origin).')
+
+        try:
+            reachable = [
+                line
+                for line in (
+                    run_process(['jj', 'log', '-r',
+                                 f'(remote_bookmarks()::{tip})-', '-T',
+                                 'remote_bookmarks ++ "\n"',
+                                 '--no-graph'])
+                    .stdout
+                    .read()
+                    .splitlines()
+                )
+                if line
+            ]
+
+            if reachable:
+                return (
+                    run_process(['jj', 'log', '-r',
+                                 f'latest({" | ".join(reachable)})',
+                                 '-T', 'remote_bookmarks', '--no-graph',
+                                 '-n', '1'])
+                    .stdout
+                    .read()
+                    .strip()
+                )
+        except RunProcessError as e:
+            logger.warning(
+                'Unable to get log for reachable commits on remote '
+                'bookmarks: %s',
+                e)
+
+        try:
+            return (
+                run_process(['jj', 'log', '-r', 'trunk()', '-T',
+                             'remote_bookmarks', '--no-graph', '-n', '1'])
+                .stdout
+                .read()
+            )
+        except RunProcessError as e:
+            logger.warning(
+                'Unable to get log for remote_bookmarks on trunk: %s', e)
+
+        raise SCMError(_(
+            'Unable to determine parent or tracking bookmark for '
+            'remote.',
+        ))
+
+    def _get_change_id(
+        self,
+        revset: str,
+    ) -> str:
+        """Return the change ID of the given revset.
+
+        This method assumes that the revset is referring to a single change or
+        commit. The results will be limited to one change ID.
+
+        Args:
+            revset (str):
+                The revset to query.
+
+        Returns:
+            str:
+            The change ID.
+
+        Raises:
+            rbtools.utils.process.RunProcessError:
+                An error occurred while running :command:`jj`.
+        """
+        return (
+            run_process(['jj', 'log', '-r', revset, '-n', '1', '--no-graph',
+                         '-T', 'change_id'])
+            .stdout
+            .read()
+        )
+
+    def _get_change_ids(
+        self,
+        revset: str,
+    ) -> Sequence[str]:
+        """Return all change IDs for the given revset.
+
+        Args:
+            revset (str):
+                The revset to query.
+
+        Returns:
+            list of str:
+            A list of all the change IDs in the revset.
+
+        Raises:
+            rbtools.utils.process.RunProcessError:
+                An error occurred while running :command:`jj`.
+        """
+        return (
+            run_process(['jj', 'log', '-r', revset, '--no-graph',
+                         '-T', 'change_id ++ "\n"'])
+            .stdout
+            .read()
+            .splitlines()
+        )
+
+    def _get_remotes(
+        self,
+    ) -> Sequence[str]:
+        """Return the Git remotes for the repository.
+
+        Returns:
+            list of str:
+            A list of remotes.
+
+        Raises:
+            rbtools.utils.process.RunProcessError:
+                An error occurred while running :command:`jj`.
+        """
+        return (
+            run_process(['jj', 'git', 'remote', 'list'])
+            .stdout
+            .read()
+            .strip()
+            .splitlines()
+        )
+
+    def _get_fork_point(
+        self,
+        revset1: str,
+        revset2: str,
+    ) -> str:
+        """Return the change ID of the fork point of two revsets.
+
+        This will determine the point at which the history from two commits
+        diverged. This is most useful for determining the most recent upstream
+        commit to work from when creating parent diffs.
+
+        Args:
+            revset1 (str):
+                The revset (referring to a single change) of the first branch.
+
+            revset2 (str):
+                The revset (referring to a single change) of the second branch.
+
+        Returns:
+            str:
+            The ID of the change at which the branches containing the two
+            referenced revsets diverged.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred attempting to get the fork point.
+        """
+        try:
+            return (
+                run_process(['jj', 'log', '-r',
+                             f'fork_point({revset1} | {revset2})',
+                             '--no-graph', '-T', 'change_id'])
+                .stdout
+                .read()
+            )
+        except RunProcessError as e:
+            raise SCMError(
+                _('Unable to determine the fork point between revisions '
+                  '"{revset1}" and "{revset2}": {error}')
+                .format(
+                    revset1=revset1,
+                    revset2=revset2,
+                    error=e,
+                )
+            )
+
+    def _expand_short_git_indexes(
+        self,
+        diff_lines: Sequence[bytes],
+    ) -> Iterable[bytes]:
+        """Expand short indexes in a Git-style diff.
+
+        Jujutsu's ``jj diff --git`` command returns a diff that works, but
+        there's no way to tell it to use full indexes.
+
+        Args:
+            diff_lines (iterable of bytes):
+                The lines in the diff.
+
+        Yields:
+            bytes:
+            Each line of the diff, with the index lines expanded.
+
+        Raises:
+            rbtools.clients.errors.SCMError:
+                An error occurred while processing the diff.
+        """
+        index_re = re.compile(
+            br'^index (?P<a>[0-9a-f]+)..(?P<b>[0-9a-f]+) (?P<rest>.*)$')
+        sha_result_re = re.compile(r'^(?P<sha>[0-9a-f]+) blob \d+$')
+
+        with subprocess.Popen(
+            ['git', 'cat-file', '--batch-check'],
+            cwd=self._git_store,
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            close_fds=True,
+            text=True,
+        ) as p:
+            assert p.stdin is not None
+            assert p.stdout is not None
+
+            partial_hashes: list[str] = []
+            full_hashes: list[str] = []
+            index_lines: set[int] = set()
+            index_lines_rest: list[str] = []
+
+            for i, line in enumerate(diff_lines):
+                m = index_re.match(line)
+
+                if m:
+                    sha_a = m.group('a').decode()
+                    sha_b = m.group('b').decode()
+                    rest = m.group('rest').decode()
+
+                    partial_hashes.append(sha_a)
+                    partial_hashes.append(sha_b)
+
+                    p.stdin.write(f'{sha_a}\n')
+                    p.stdin.write(f'{sha_b}\n')
+
+                    index_lines.add(i)
+                    index_lines_rest.append(rest)
+
+            p.stdin.close()
+
+            sha_results = p.stdout.read().splitlines()
+
+            for i, result in enumerate(sha_results):
+                m = sha_result_re.match(result)
+
+                if m:
+                    sha = m.group('sha')
+                    full_hashes.append(sha)
+                else:
+                    partial_sha = partial_hashes[i]
+
+                    logger.error('Got unexpected result when finding full Git '
+                                 'file SHA for partial %s: "%s"',
+                                 partial_sha, result)
+                    raise SCMError(_(
+                        'Unable to create Git-style diff for Jujutsu: full '
+                        'file SHA for {partial_sha} could not be found.')
+                        .format(partial_sha=partial_sha))
+
+            full_hashes.reverse()
+            index_lines_rest.reverse()
+
+            for i, line in enumerate(diff_lines):
+                if i in index_lines:
+                    sha_a = full_hashes.pop()
+                    sha_b = full_hashes.pop()
+                    rest = index_lines_rest.pop()
+
+                    yield f'index {sha_a}..{sha_b} {rest}\n'.encode()
+                else:
+                    yield line
diff --git a/rbtools/clients/tests/test_jujutsu.py b/rbtools/clients/tests/test_jujutsu.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a5a5817c93982d30894b9db52b26016b185f0c5
--- /dev/null
+++ b/rbtools/clients/tests/test_jujutsu.py
@@ -0,0 +1,1812 @@
+"""Unit tests for JujutsuClient.
+
+Version Added:
+    6.0
+"""
+
+from __future__ import annotations
+
+import os
+from typing import ClassVar, Optional
+
+import kgb
+
+from rbtools.clients.base.repository import RepositoryInfo
+from rbtools.clients.errors import (
+    AmendError,
+    PushError,
+    SCMError,
+    SCMClientDependencyError,
+    TooManyRevisionsError,
+)
+from rbtools.clients.jujutsu import JujutsuClient
+from rbtools.clients.tests import (FOO, FOO1, FOO2, FOO3, FOO4,
+                                   SCMClientTestCase)
+from rbtools.diffs.patches import Patch, PatchAuthor
+from rbtools.utils.checks import check_install
+from rbtools.utils.filesystem import make_tempdir
+from rbtools.utils.process import run_process
+
+
+class BaseJujutsuClientTests(SCMClientTestCase[JujutsuClient]):
+    """Base class for unit tests for JujutsuClient.
+
+    Version Added:
+        6.0
+    """
+
+    #: The SCMClient class to instantiate.
+    scmclient_cls = JujutsuClient
+
+    #: The git repository to clone.
+    git_upstream: ClassVar[str]
+
+    #: The directory of the repository clone to use for tests.
+    jj_dir: ClassVar[str]
+
+    @classmethod
+    def setup_checkout(
+        cls,
+        checkout_dir: str,
+    ) -> Optional[str]:
+        """Populate a Jujutsu checkout.
+
+        This will create a Jujutsu clone of the sample Git repository stored in
+        the :file:`testdata` directory.
+
+        Args:
+            checkout_dir (str):
+                The top-level directory in which the clone will be placed.
+
+        Returns:
+            str:
+            The main checkout directory, or ``None`` if the dependencies for
+            the tool are not installed.
+        """
+        scmclient = JujutsuClient()
+
+        if not scmclient.has_dependencies():
+            return None
+
+        cls.git_upstream = os.path.join(cls.testdata_dir, 'git-repo')
+        cls.jj_dir = checkout_dir
+
+        run_process(['jj', 'git', 'clone', cls.git_upstream, checkout_dir])
+
+        return checkout_dir
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
+
+        self.set_user_home(os.path.join(self.testdata_dir, 'homedir'))
+        os.environ['JJ_USER'] = 'Test user'
+        os.environ['JJ_EMAIL'] = 'test@example.com'
+
+    def _add_file_to_repo(
+        self,
+        *,
+        filename: str,
+        data: bytes,
+        message: str,
+        commit: bool = True,
+    ) -> None:
+        """Add a file and optionally commit the result.
+
+        Args:
+            filename (str):
+                The filename to add.
+
+            data (bytes):
+                The content of the file.
+
+            message (str):
+                The commit message.
+
+            commit (bool, optional):
+                Whether to finalize the commit and start a new change.
+        """
+        with open(filename, 'wb') as f:
+            f.write(data)
+
+        run_process(['jj', 'describe', '-m', message])
+
+        if commit:
+            run_process(['jj', 'new'])
+
+
+class JujutsuClientTests(BaseJujutsuClientTests):
+    """Unit tests for JujutsuClient.
+
+    Version Added:
+        6.0
+    """
+
+    def test_check_dependencies_found(self) -> None:
+        """Testing JujutsuClient.check_dependencies with all dependencies
+        found
+        """
+        self.spy_on(check_install, op=kgb.SpyOpMatchAny([
+            {
+                'args': (['git', '--help'],),
+                'op': kgb.SpyOpReturn(True),
+            },
+            {
+                'args': (['jj', '--help'],),
+                'op': kgb.SpyOpReturn(True),
+            },
+        ]))
+
+        client = self.build_client(setup=False)
+        client.check_dependencies()
+
+        self.assertSpyCallCount(check_install, 2)
+        self.assertSpyCalledWith(check_install, ['git', '--help'])
+        self.assertSpyCalledWith(check_install, ['jj', '--help'])
+
+    def test_check_dependencies_missing_jj(self) -> None:
+        """Testing JujutsuClient.check_dependencies with ``jj`` missing"""
+        self.spy_on(check_install, op=kgb.SpyOpMatchAny([
+            {
+                'args': (['git', '--help'],),
+                'op': kgb.SpyOpReturn(True),
+            },
+            {
+                'args': (['jj', '--help'],),
+                'op': kgb.SpyOpReturn(False),
+            },
+        ]))
+
+        client = self.build_client(setup=False)
+
+        message = "Command line tools ('jj') are missing."
+
+        with self.assertRaisesMessage(SCMClientDependencyError, message):
+            client.check_dependencies()
+
+    def test_check_dependencies_missing_git(self) -> None:
+        """Testing JujutsuClient.check_dependencies with ``git`` missing"""
+        self.spy_on(check_install, op=kgb.SpyOpMatchAny([
+            {
+                'args': (['git', '--help'],),
+                'op': kgb.SpyOpReturn(False),
+            },
+            {
+                'args': (['jj', '--help'],),
+                'op': kgb.SpyOpReturn(True),
+            },
+        ]))
+
+        client = self.build_client(setup=False)
+
+        message = "Command line tools ('git') are missing."
+
+        with self.assertRaisesMessage(SCMClientDependencyError, message):
+            client.check_dependencies()
+
+    def test_get_repository_info(self) -> None:
+        """Testing JujutsuClient.get_repository_info"""
+        client = self.build_client()
+        info = client.get_repository_info()
+        assert info is not None
+
+        self.assertIsInstance(info, RepositoryInfo)
+        self.assertEqual(info.base_path, '')
+        self.assertEqual(info.path, self.git_upstream)
+        self.assertEqual(info.local_path,
+                         os.path.realpath(self.jj_dir))
+
+    def test_parse_revision_spec_no_revisions(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with no specified
+        revisions
+        """
+        client = self.build_client()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+
+        base = client._get_change_id('master@origin')
+        tip = client._get_change_id('@')
+
+        self.assertEqual(
+            client.parse_revision_spec(),
+            {
+                'base': base,
+                'commit_id': tip,
+                'tip': tip,
+            })
+
+    def test_parse_revision_spec_no_revisions_with_parent(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with no specified
+        revisions and a parent diff
+        """
+        client = self.build_client(options={
+            'parent_branch': 'parent',
+        })
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Parent commit')
+
+        run_process(['jj', 'bookmark', 'create', 'parent'])
+
+        self._add_file_to_repo(filename='foo2.txt', data=FOO2,
+                               message='Child commit')
+
+        parent_base = client._get_change_id('master@origin')
+        base = client._get_change_id('parent')
+        tip = client._get_change_id('@')
+
+        self.assertEqual(
+            client.parse_revision_spec(),
+            {
+                'base': base,
+                'commit_id': tip,
+                'parent_base': parent_base,
+                'tip': tip,
+            })
+
+    def test_parse_revision_spec_one_revision(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with one specified
+        revision
+        """
+        client = self.build_client()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+
+        tip = client._get_change_id('@')
+        base = client._get_change_id('@-')
+        parent_base = client._get_change_id('master@origin')
+
+        self._add_file_to_repo(filename='foo2.txt', data=FOO2,
+                               message='Commit 2')
+
+        self.assertEqual(
+            client.parse_revision_spec([tip]),
+            {
+                'base': base,
+                'commit_id': tip,
+                'parent_base': parent_base,
+                'tip': tip,
+            })
+
+    def test_parse_revision_spec_one_revision_with_parent(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with one specified
+        revision and a parent diff
+        """
+        client = self.build_client(options={
+            'parent_branch': 'parent',
+        })
+        parent_base = client._get_change_id('master@origin')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Parent commit')
+        run_process(['jj', 'bookmark', 'create', 'parent'])
+
+        self._add_file_to_repo(filename='foo2.txt', data=FOO2,
+                               message='Child commit 1')
+
+        base = client._get_change_id('@')
+
+        self._add_file_to_repo(filename='foo3.txt', data=FOO3,
+                               message='Child commit 2')
+
+        tip = client._get_change_id('@')
+
+        self._add_file_to_repo(filename='foo4.txt', data=FOO4,
+                               message='Child commit 3')
+
+        self.assertEqual(
+            client.parse_revision_spec([tip]),
+            {
+                'base': base,
+                'commit_id': tip,
+                'parent_base': parent_base,
+                'tip': tip,
+            })
+
+    def test_parse_revision_spec_two_revisions(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with two specified
+        revisions
+        """
+        client = self.build_client(options={
+            'parent_branch': 'parent',
+        })
+        parent_base = client._get_change_id('master@origin')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Parent commit')
+        run_process(['jj', 'bookmark', 'create', 'parent'])
+
+        self._add_file_to_repo(filename='foo2.txt', data=FOO2,
+                               message='Child commit 1')
+
+        base = client._get_change_id('@')
+
+        self._add_file_to_repo(filename='foo3.txt', data=FOO3,
+                               message='Child commit 2')
+        self._add_file_to_repo(filename='foo4.txt', data=FOO4,
+                               message='Child commit 3')
+
+        tip = client._get_change_id('@')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO4,
+                               message='Child commit 4')
+
+        self.assertEqual(
+            client.parse_revision_spec([base, tip]),
+            {
+                'base': base,
+                'commit_id': tip,
+                'parent_base': parent_base,
+                'tip': tip,
+            })
+
+    def test_parse_revision_spec_too_many_revisions(self) -> None:
+        """Testing JujutsuClient.parse_revision_spec with too many revisions
+        """
+        client = self.build_client()
+
+        with self.assertRaises(TooManyRevisionsError):
+            client.parse_revision_spec(['1', '2', '3'])
+
+    def test_diff_with_working_copy(self) -> None:
+        """Testing JujutsuClient.diff with the working copy change"""
+        client = self.build_client(needs_diff=True)
+        client.get_repository_info()
+
+        base_commit_id = client._get_change_id('@-')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1', commit=False)
+        commit_id = client._get_change_id('@')
+
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(
+            client.diff(revisions),
+            {
+                'base_commit_id': base_commit_id,
+                'commit_id': commit_id,
+                'diff': (
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                'parent_diff': None,
+            })
+
+    def test_diff_with_multiple_commits(self) -> None:
+        """Testing JujutsuClient.diff with multiple commits"""
+        client = self.build_client(needs_diff=True)
+        client.get_repository_info()
+
+        base_commit_id = client._get_change_id('@-')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2')
+        self._add_file_to_repo(filename='foo.txt', data=FOO3,
+                               message='Commit 3', commit=False)
+
+        commit_id = client._get_change_id('@')
+
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(
+            client.diff(revisions),
+            {
+                'base_commit_id': base_commit_id,
+                'commit_id': commit_id,
+                'diff': (
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'63036ed3fcafe870d567a14dd5884f4fed70126c 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -1,12 +1,11 @@\n ARMA virumque cano, Troiae '
+                    b'qui primus ab oris\n'
+                    b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b' Italiam, fato profugus, Laviniaque venit\n'
+                    b' litora, multum ille et terris iactatus et alto\n'
+                    b' vi superum saevae memorem Iunonis ob iram;\n'
+                    b'-multa quoque et bello passus, dum conderet urbem,\n'
+                    b'+dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b'+Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                'parent_diff': None,
+            })
+
+    def test_diff_with_exclude_patterns(self) -> None:
+        """Testing JujutsuClient.diff with file exclusion"""
+        client = self.build_client(needs_diff=True)
+        client.get_repository_info()
+        base_commit_id = client._get_change_id('@-')
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='exclude.txt', data=FOO2,
+                               message='Commit 2', commit=False)
+        commit_id = client._get_change_id('@')
+
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(
+            client.diff(revisions, exclude_patterns=['exclude.txt']),
+            {
+                'commit_id': commit_id,
+                'base_commit_id': base_commit_id,
+                'diff': (
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                'parent_diff': None,
+            })
+
+    def test_diff_exclude_in_subdir(self) -> None:
+        """Testing JujutsuClient.diff with file exclusion in a subdir"""
+        client = self.build_client(needs_diff=True)
+        client.get_repository_info()
+        base_commit_id = client._get_change_id('@-')
+
+        os.mkdir('subdir')
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='subdir/exclude.txt', data=FOO2,
+                               message='Commit 2', commit=False)
+
+        os.chdir('subdir')
+
+        commit_id = client._get_change_id('@')
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(
+            client.diff(revisions, exclude_patterns=['exclude.txt']),
+            {
+                'commit_id': commit_id,
+                'base_commit_id': base_commit_id,
+                'diff': (
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                'parent_diff': None,
+            })
+
+    def test_diff_with_exclude_patterns_root_pattern_in_subdir(self) -> None:
+        """Testing JujutsuClient.diff with file exclusion in the repo root"""
+        client = self.build_client(needs_diff=True)
+        client.get_repository_info()
+        base_commit_id = client._get_change_id('@-')
+
+        os.mkdir('subdir')
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='exclude.txt', data=FOO2,
+                               message='Commit 2', commit=False)
+        os.chdir('subdir')
+
+        commit_id = client._get_change_id('@')
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(
+            client.diff(revisions,
+                        exclude_patterns=[os.path.sep + 'exclude.txt']),
+            {
+                'commit_id': commit_id,
+                'base_commit_id': base_commit_id,
+                'diff': (
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                'parent_diff': None,
+            })
+
+    def test_get_raw_commit_message(self) -> None:
+        """Testing JujutsuClient.get_raw_commit_message"""
+        client = self.build_client()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Working copy commit', commit=False)
+        client.get_repository_info()
+        revisions = client.parse_revision_spec([])
+
+        self.assertEqual(client.get_raw_commit_message(revisions),
+                         'Working copy commit')
+
+    def test_get_raw_commit_message_with_revision_range(self) -> None:
+        """Testing JujutsuClient.get_raw_commit_message with a revision
+        range
+        """
+        client = self.build_client()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2')
+        self._add_file_to_repo(filename='foo.txt', data=FOO3,
+                               message='Working copy commit', commit=False)
+        client.get_repository_info()
+        revisions = client.parse_revision_spec(['trunk()', '@-'])
+
+        self.assertEqual(client.get_raw_commit_message(revisions),
+                         'Commit 1\nCommit 2')
+
+    def test_get_commit_history(self) -> None:
+        """Testing JujutsuClient.get_commit_history"""
+        client = self.build_client()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2')
+        self._add_file_to_repo(filename='foo.txt', data=FOO3,
+                               message='Working copy commit', commit=False)
+        client.get_repository_info()
+        revisions = client.parse_revision_spec(['trunk()', '@'])
+
+        commits = client.get_commit_history(revisions)
+        assert commits is not None
+
+        self.assertEqual(len(commits), 3)
+        self.assertEqual(commits[0]['commit_message'], 'Commit 1')
+        self.assertEqual(commits[1]['commit_message'], 'Commit 2')
+        self.assertEqual(commits[2]['commit_message'], 'Working copy commit')
+        self.assertEqual(commits[0]['commit_id'], commits[1]['parent_id'])
+        self.assertEqual(commits[1]['commit_id'], commits[2]['parent_id'])
+
+    def test_get_file_content(self) -> None:
+        """Testing JujutsuClient.get_file_content"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        # This is just a blob which exists in the existing testdata git repo.
+        self.assertEqual(
+            client.get_file_content(
+                filename='foo.txt',
+                revision='634b3e8ff85bada6f928841a9f2c505560840b3a'),
+            FOO)
+
+    def test_get_file_content_invalid_revision(self) -> None:
+        """Testing JujutsuClient.get_file_content with an invalid revision"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        with self.assertRaises(SCMError):
+            client.get_file_content(
+                filename='foo.txt',
+                revision='634b3e8ff85bada6f928841a9000000000000000')
+
+    def test_get_file_size(self) -> None:
+        """Testing JujutsuClient.get_file_size"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        # This is just a blob which exists in the existing testdata git repo.
+        self.assertEqual(
+            client.get_file_size(
+                filename='foo.txt',
+                revision='634b3e8ff85bada6f928841a9f2c505560840b3a'),
+            len(FOO))
+
+    def test_get_file_size_invalid_revision(self) -> None:
+        """Testing JujutsuClient.get_file_size with an invalid revision"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        with self.assertRaises(SCMError):
+            client.get_file_size(
+                filename='foo.txt',
+                revision='634b3e8ff85bada6f928841a9000000000000000')
+
+    def test_get_current_bookmark(self) -> None:
+        """Testing JujutsuClient.get_current_bookmark"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        run_process(['jj', 'bookmark', 'create', 'current-bookmark'])
+
+        self.assertEqual(client.get_current_bookmark(), 'current-bookmark')
+
+    def test_amend_commit_description(self) -> None:
+        """Testing JujutsuClient.amend_commit_description"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit', commit=False)
+
+        change = client._get_change_id('@')
+
+        client.amend_commit_description('New commit message')
+
+        description = (
+            run_process(['jj', 'log', '-T', 'description', '--no-graph', '-r',
+                         change])
+            .stdout
+            .read()
+        )
+
+        self.assertEqual(description, 'New commit message\n')
+
+    def test_amend_commit_description_non_editing_change(self) -> None:
+        """Testing JujutsuClient.amend_commit_description with a change which
+        is not the current working copy (@)
+        """
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2', commit=False)
+
+        change = client._get_change_id('@-')
+        wc = client._get_change_id('@')
+
+        client.amend_commit_description(
+            'Commit 1 new commit message',
+            revisions={
+                'base': f'{change}-',
+                'tip': change,
+            })
+
+        changed_description = (
+            run_process(['jj', 'log', '-T', 'description', '--no-graph', '-r',
+                         change])
+            .stdout
+            .read()
+        )
+
+        self.assertEqual(changed_description, 'Commit 1 new commit message\n')
+
+        unchanged_description = (
+            run_process(['jj', 'log', '-T', 'description', '--no-graph', '-r',
+                         wc])
+            .stdout
+            .read()
+        )
+
+        self.assertEqual(unchanged_description, 'Commit 2\n')
+
+    def test_amend_commit_description_with_immutable_change(self) -> None:
+        """Testing JujutsuClient.amend_commit_description with an immutable
+        change
+        """
+        client = self.build_client()
+        client.get_repository_info()
+
+        change = client._get_change_id('@-')
+
+        with self.assertRaises(AmendError):
+            client.amend_commit_description(
+                'New commit message',
+                revisions={
+                    'base': f'{change}-',
+                    'tip': change,
+                })
+
+    def test_create_commit(self) -> None:
+        """Testing JujutsuClient.create_commit"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        with open('foo.txt', 'wb') as f:
+            f.write(FOO1)
+
+        commit_message = 'summary\n\ndescription\ndescription 2'
+
+        client.create_commit(
+            message=commit_message,
+            author=PatchAuthor(full_name='Test User',
+                               email='test@example.com'),
+            run_editor=False)
+
+        status = (
+            run_process(['jj', 'status'])
+            .stdout
+            .read()
+        )
+        self.assertIn('The working copy is clean', status)
+
+        message = (
+            run_process(['jj', 'log', '-r' '@-', '--no-graph', '-T',
+                         'description'])
+            .stdout
+            .read()
+            .strip()
+        )
+        self.assertEqual(message, commit_message)
+
+        author = (
+            run_process(['jj', 'log', '-r', '@-', '--no-graph', '-T',
+                         'author'])
+            .stdout
+            .read()
+            .strip()
+        )
+        self.assertEqual(author, 'Test User <test@example.com>')
+
+    def test_merge_one_change(self) -> None:
+        """Testing JujutsuClient.merge in merge mode with one change"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit to merge')
+
+        upstream = client._get_change_id('master@origin')
+        change_to_merge = client._get_change_id('@-')
+
+        client.merge(
+            target=change_to_merge,
+            destination='master',
+            message='Merged change',
+            author=PatchAuthor(full_name='Joe User', email='joe@example.com'))
+
+        tip = client._get_change_id('@')
+
+        # Check that the tip is a new merge commit that has the destination
+        # branch and target change as its parents.
+        parents = set(
+            run_process(['jj', 'log', '-T', 'parents.map(|c| c.change_id())',
+                         '--no-graph', '-r', tip])
+            .stdout
+            .read()
+            .strip()
+            .split())
+
+        self.assertEqual(parents, {upstream, change_to_merge})
+
+        # Check that we updated the bookmark to point to the new merge commit.
+        bookmark = client._get_change_id('master')
+        self.assertEqual(bookmark, tip)
+
+    def test_merge_multiple_changes(self) -> None:
+        """Testing JujutsuClient.merge in merge mode with multiple changes"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2')
+        self._add_file_to_repo(filename='foo.txt', data=FOO3,
+                               message='Commit to merge')
+
+        upstream = client._get_change_id('master@origin')
+        change_to_merge = client._get_change_id('@-')
+
+        client.merge(
+            target=change_to_merge,
+            destination='master',
+            message='Merged change',
+            author=PatchAuthor(full_name='Joe User', email='joe@example.com'))
+
+        tip = client._get_change_id('@')
+
+        # Check that the tip is a new merge commit that has the destination
+        # branch and target change as its parents.
+        parents = set(
+            run_process(['jj', 'log', '-T', 'parents.map(|c| c.change_id())',
+                         '--no-graph', '-r', tip])
+            .stdout
+            .read()
+            .strip()
+            .split())
+
+        self.assertEqual(parents, {upstream, change_to_merge})
+
+        # Check that we updated the bookmark to point to the new merge commit.
+        bookmark = client._get_change_id('master')
+        self.assertEqual(bookmark, tip)
+
+    def test_merge_squash_one_change(self) -> None:
+        """Testing JujutsuClient.merge in squash mode with one change"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit to merge')
+
+        upstream = client._get_change_id('master@origin')
+        change_to_merge = client._get_change_id('@-')
+
+        client.merge(
+            target=change_to_merge,
+            destination='master',
+            message='Merged change',
+            author=PatchAuthor(full_name='Joe User', email='joe@example.com'),
+            squash=True)
+
+        tip = client._get_change_id('@')
+
+        # Check that the tip is a new commit that has only the destination
+        # branch as its parent.
+        parents = set(
+            run_process(['jj', 'log', '-T', 'parents.map(|c| c.change_id())',
+                         '--no-graph', '-r', tip])
+            .stdout
+            .read()
+            .strip()
+            .split())
+
+        self.assertEqual(parents, {upstream})
+
+        # Check that we updated the bookmark to point to the new merge commit.
+        bookmark = client._get_change_id('master')
+        self.assertEqual(bookmark, tip)
+
+    def test_merge_squash_multiple_changes(self) -> None:
+        """Testing JujutsuClient.merge in squash mode with multiple changes"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit 1')
+        self._add_file_to_repo(filename='foo.txt', data=FOO2,
+                               message='Commit 2')
+        self._add_file_to_repo(filename='foo.txt', data=FOO3,
+                               message='Commit to merge')
+
+        upstream = client._get_change_id('master@origin')
+        change_to_merge = client._get_change_id('@-')
+
+        client.merge(
+            target=change_to_merge,
+            destination='master',
+            message='Merged change',
+            author=PatchAuthor(full_name='Joe User', email='joe@example.com'),
+            squash=True)
+
+        tip = client._get_change_id('@')
+
+        # Check that the tip is a new commit that has only the destination
+        # branch as its parent.
+        parents = set(
+            run_process(['jj', 'log', '-T', 'parents.map(|c| c.change_id())',
+                         '--no-graph', '-r', tip])
+            .stdout
+            .read()
+            .strip()
+            .split())
+
+        self.assertEqual(parents, {upstream})
+
+        # Check that we updated the bookmark to point to the new merge commit.
+        bookmark = client._get_change_id('master')
+        self.assertEqual(bookmark, tip)
+
+    def test_push_upstream(self) -> None:
+        """Testing JujutsuClient.push_upstream"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        # We need to make another copy of the git repo so we don't push into
+        # our testdata repo.
+        other_remote = make_tempdir()
+        run_process(['git', 'clone', '--bare', self.git_upstream,
+                     other_remote])
+        run_process(['jj', 'git', 'remote', 'set-url', 'origin', other_remote])
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit', commit=False)
+        run_process(['jj', 'bookmark', 'move', '--to', '@', 'master'])
+
+        tip = client._get_change_id('@')
+
+        client.push_upstream('master')
+
+        self.assertEqual(client._get_change_id('master@origin'), tip)
+
+    def test_push_upstream_invalid_bookmark(self) -> None:
+        """Testing JujutsuClient.push_upstream with an invalid bookmark"""
+        client = self.build_client()
+        client.get_repository_info()
+
+        # We need to make another copy of the git repo so we don't push into
+        # our testdata repo.
+        other_remote = make_tempdir()
+        run_process(['git', 'clone', '--bare', self.git_upstream,
+                     other_remote])
+        run_process(['jj', 'git', 'remote', 'set-url', 'origin', other_remote])
+
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Commit', commit=False)
+        run_process(['jj', 'bookmark', 'move', '--to', '@', 'master'])
+
+        with self.assertRaises(PushError):
+            client.push_upstream('master2')
+
+    def test_has_pending_changes(self) -> None:
+        """Testing JujutsuClient.has_pending_changes"""
+        client = self.build_client()
+
+        self.assertFalse(client.has_pending_changes())
+
+        # In Jujutsu, the "working copy" is a real commit, and we don't mind if
+        # it has content because that doesn't block us from doing any
+        # operations. Our implementation of patch/merge/etc. are designed to
+        # not affect whatever is the current working copy.
+        self._add_file_to_repo(filename='foo.txt', data=FOO1,
+                               message='Working copy', commit=False)
+        self.assertFalse(client.has_pending_changes())
+
+
+class JujutsuPatcherTests(BaseJujutsuClientTests):
+    """Unit tests for JujutsuPatcher.
+
+    Version Added:
+        6.0
+    """
+
+    def test_patch(self) -> None:
+        """Testing JujutsuPatcher.patch"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(patches=[
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                b'dum conderet urbem,\n'
+                b' inferretque deos Latio, genus unde Latinum,\n'
+                b' Albanique patres, atque altae moenia Romae.\n'
+                b' Musa, mihi causas memora, quo numine laeso,\n'
+                b'-quidve dolens, regina deum tot volvere casus\n'
+                b'-insignem pietate virum, tot adire labores\n'
+                b'-impulerit. Tantaene animis caelestibus irae?\n'
+                b' \n'
+            )),
+        ])
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO1)
+
+        # The tip should still be the same.
+        self.assertEqual(client._get_change_id('@'), old_tip)
+
+    def test_patch_with_commit(self) -> None:
+        """Testing JujutsuPatcher.patch with committing"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(patches=[
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                b'dum conderet urbem,\n'
+                b' inferretque deos Latio, genus unde Latinum,\n'
+                b' Albanique patres, atque altae moenia Romae.\n'
+                b' Musa, mihi causas memora, quo numine laeso,\n'
+                b'-quidve dolens, regina deum tot volvere casus\n'
+                b'-insignem pietate virum, tot adire labores\n'
+                b'-impulerit. Tantaene animis caelestibus irae?\n'
+                b' \n'
+            )),
+        ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO1)
+
+        new_parent = client._get_change_id('@-')
+
+        # The new parent should be our original tip.
+        self.assertEqual(new_parent, old_tip)
+
+        self.assertEqual(self._get_description(new_parent), 'Test message')
+
+    def test_patch_with_non_empty_wc(self) -> None:
+        """Testing JujutsuPatcher.patch with a non-empty working copy commit"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+        old_parent = client._get_change_id('@-')
+
+        self._add_file_to_repo(filename='foo2.txt', data=FOO2,
+                               message='WC commit', commit=False)
+
+        patcher = client.get_patcher(patches=[
+            Patch(
+                content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                author=PatchAuthor(full_name='Test User',
+                                   email='test@example.com'),
+                message='Test message',
+            ),
+        ])
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO1)
+
+        new_head = client._get_change_id('@')
+        new_parent = client._get_change_id('@-')
+        new_grandparent = client._get_change_id('@--')
+
+        # The new parent should be our old WC.
+        self.assertEqual(new_parent, old_tip)
+
+        # The grandparent of the current WC should be our previous parent.
+        self.assertEqual(new_grandparent, old_parent)
+
+        # Because we didn't commit, this should be empty
+        self.assertEqual(self._get_description(new_head), '')
+
+    def test_patch_with_non_empty_wc_message_only(self) -> None:
+        """Testing JujutsuPatcher.patch with a non-empty working copy commit
+        (message only, no diff)
+        """
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+        parent = client._get_change_id('@-')
+
+        run_process(['jj', 'describe', '-m', 'WC message'])
+
+        patcher = client.get_patcher(patches=[
+            Patch(
+                content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                author=PatchAuthor(full_name='Test User',
+                                   email='test@example.com'),
+                message='Test message',
+            ),
+        ])
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO1)
+
+        new_head = client._get_change_id('@')
+        new_parent = client._get_change_id('@-')
+        new_grandparent = client._get_change_id('@--')
+
+        # The new parent should be our old WC.
+        self.assertEqual(new_parent, old_tip)
+
+        # The grandparent of the current WC should be our previous parent.
+        self.assertEqual(new_grandparent, parent)
+
+        # Because we didn't commit, this should be empty
+        self.assertEqual(self._get_description(new_head), '')
+
+    def test_patch_multiple_patches(self) -> None:
+        """Testing JujutsuPatcher.patch with multiple patches"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(patches=[
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                b'dum conderet urbem,\n'
+                b' inferretque deos Latio, genus unde Latinum,\n'
+                b' Albanique patres, atque altae moenia Romae.\n'
+                b' Musa, mihi causas memora, quo numine laeso,\n'
+                b'-quidve dolens, regina deum tot volvere casus\n'
+                b'-insignem pietate virum, tot adire labores\n'
+                b'-impulerit. Tantaene animis caelestibus irae?\n'
+                b' \n'
+            )),
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -1,4 +1,6 @@\n'
+                b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                b' Italiam, fato profugus, Laviniaque venit\n'
+                b' litora, multum ille et terris iactatus et alto\n'
+                b' vi superum saevae memorem Iunonis ob iram;\n'
+            )),
+        ])
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO2)
+
+        # The tip should still be the same.
+        self.assertEqual(client._get_change_id('@'), old_tip)
+
+    def test_patch_with_multiple_patches_and_commit(self) -> None:
+        """Testing JujutsuPatcher.patch with multiple patches and committing"""
+        client = self.build_client()
+
+        old_parent = client._get_change_id('@-')
+
+        patcher = client.get_patcher(patches=[
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                b'dum conderet urbem,\n'
+                b' inferretque deos Latio, genus unde Latinum,\n'
+                b' Albanique patres, atque altae moenia Romae.\n'
+                b' Musa, mihi causas memora, quo numine laeso,\n'
+                b'-quidve dolens, regina deum tot volvere casus\n'
+                b'-insignem pietate virum, tot adire labores\n'
+                b'-impulerit. Tantaene animis caelestibus irae?\n'
+                b' \n'
+            )),
+            Patch(content=(
+                b'diff --git a/foo.txt b/foo.txt\n'
+                b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                b'--- a/foo.txt\n'
+                b'+++ b/foo.txt\n'
+                b'@@ -1,4 +1,6 @@\n'
+                b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                b' Italiam, fato profugus, Laviniaque venit\n'
+                b' litora, multum ille et terris iactatus et alto\n'
+                b' vi superum saevae memorem Iunonis ob iram;\n'
+            )),
+        ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        greatgrandparent = client._get_change_id('@---')
+        grandparent = client._get_change_id('@--')
+        parent = client._get_change_id('@-')
+
+        self.assertEqual(greatgrandparent, old_parent)
+        self.assertEqual(self._get_description(grandparent),
+                         '[1/2] Test message')
+        self.assertEqual(self._get_description(parent),
+                         '[2/2] Test message')
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO2)
+
+    def test_patch_with_multiple_patches_and_commit_with_messages(
+        self,
+    ) -> None:
+        """Testing JujutsuPatcher.patch with multiple patches and committing
+        with per-patch messages
+        """
+        client = self.build_client()
+
+        old_parent = client._get_change_id('@-')
+
+        patcher = client.get_patcher(patches=[
+            Patch(
+                content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                ),
+                message='Commit message 1'),
+            Patch(
+                content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                    b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -1,4 +1,6 @@\n'
+                    b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b' Italiam, fato profugus, Laviniaque venit\n'
+                    b' litora, multum ille et terris iactatus et alto\n'
+                    b' vi superum saevae memorem Iunonis ob iram;\n'
+                ),
+                message='Commit message 2'),
+        ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        greatgrandparent = client._get_change_id('@---')
+        grandparent = client._get_change_id('@--')
+        parent = client._get_change_id('@-')
+
+        self.assertEqual(greatgrandparent, old_parent)
+        self.assertEqual(self._get_description(grandparent),
+                         'Commit message 1')
+        self.assertEqual(self._get_description(parent),
+                         'Commit message 2')
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO2)
+
+    def test_patch_multiple_patches_squash(self) -> None:
+        """Testing JujutsuPatcher.patch with multiple patches in squash mode
+        """
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(
+            patches=[
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'-quidve dolens, regina deum tot volvere casus\n'
+                    b'-insignem pietate virum, tot adire labores\n'
+                    b'-impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                )),
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                    b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -1,4 +1,6 @@\n'
+                    b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b' Italiam, fato profugus, Laviniaque venit\n'
+                    b' litora, multum ille et terris iactatus et alto\n'
+                    b' vi superum saevae memorem Iunonis ob iram;\n'
+                )),
+            ],
+            squash=True)
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO2)
+
+        # We should have just applied the patches to the existing empty WC
+        # commit.
+        self.assertEqual(client._get_change_id('@'), old_tip)
+
+    def test_patch_multiple_patches_squash_and_commit(self) -> None:
+        """Testing JujutsuPatcher.patch with multiple patches in squash mode
+        and committing
+        """
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(
+            patches=[
+                Patch(
+                    content=(
+                        b'diff --git a/foo.txt b/foo.txt\n'
+                        b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                        b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                        b'--- a/foo.txt\n'
+                        b'+++ b/foo.txt\n'
+                        b'@@ -6,7 +6,4 @@ multa quoque et bello passus, '
+                        b'dum conderet urbem,\n'
+                        b' inferretque deos Latio, genus unde Latinum,\n'
+                        b' Albanique patres, atque altae moenia Romae.\n'
+                        b' Musa, mihi causas memora, quo numine laeso,\n'
+                        b'-quidve dolens, regina deum tot volvere casus\n'
+                        b'-insignem pietate virum, tot adire labores\n'
+                        b'-impulerit. Tantaene animis caelestibus irae?\n'
+                        b' \n'
+                    ),
+                    message='commit message'),
+                Patch(
+                    content=(
+                        b'diff --git a/foo.txt b/foo.txt\n'
+                        b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                        b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                        b'--- a/foo.txt\n'
+                        b'+++ b/foo.txt\n'
+                        b'@@ -1,4 +1,6 @@\n'
+                        b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                        b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                        b'+ARMA virumque cano, Troiae qui primus ab oris\n'
+                        b' Italiam, fato profugus, Laviniaque venit\n'
+                        b' litora, multum ille et terris iactatus et alto\n'
+                        b' vi superum saevae memorem Iunonis ob iram;\n'
+                    ),
+                    message='commit message 2'),
+            ],
+            squash=True)
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(fp.read(), FOO2)
+
+        # Our new parent commit should be our old tip commit.
+        parent = client._get_change_id('@-')
+        self.assertEqual(parent, old_tip)
+
+        # We should use the default message (computed from the review request)
+        # instead of the individual commit messages.
+        self.assertEqual(self._get_description(parent), 'Test message')
+
+    def test_patch_with_revert(self) -> None:
+        """Testing JujutsuPatcher with revert"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(
+            revert=True,
+            patches=[
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,4 +6,7 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'+quidve dolens, regina deum tot volvere casus\n'
+                    b'+insignem pietate virum, tot adire labores\n'
+                    b'+impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                )),
+            ])
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(
+                fp.read(),
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'Italiam, fato profugus, Laviniaque venit\n'
+                b'litora, multum ille et terris iactatus et alto\n'
+                b'vi superum saevae memorem Iunonis ob iram;\n'
+                b'multa quoque et bello passus, dum conderet urbem,\n'
+                b'inferretque deos Latio, genus unde Latinum,\n'
+                b'Albanique patres, atque altae moenia Romae.\n'
+                b'Musa, mihi causas memora, quo numine laeso,\n\n')
+
+        # The tip should still be the same.
+        self.assertEqual(client._get_change_id('@'), old_tip)
+
+    def test_patch_with_revert_and_commit(self) -> None:
+        """Testing JujutsuPatcher with revert and commit"""
+        client = self.build_client()
+
+        old_tip = client._get_change_id('@')
+
+        patcher = client.get_patcher(
+            revert=True,
+            patches=[
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,4 +6,7 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'+quidve dolens, regina deum tot volvere casus\n'
+                    b'+insignem pietate virum, tot adire labores\n'
+                    b'+impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                )),
+            ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 1)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(
+                fp.read(),
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'Italiam, fato profugus, Laviniaque venit\n'
+                b'litora, multum ille et terris iactatus et alto\n'
+                b'vi superum saevae memorem Iunonis ob iram;\n'
+                b'multa quoque et bello passus, dum conderet urbem,\n'
+                b'inferretque deos Latio, genus unde Latinum,\n'
+                b'Albanique patres, atque altae moenia Romae.\n'
+                b'Musa, mihi causas memora, quo numine laeso,\n\n')
+
+        # The new parent should still be our old tip.
+        parent = client._get_change_id('@-')
+        self.assertEqual(parent, old_tip)
+
+        self.assertEqual(self._get_description(parent),
+                         '[Revert] Test message')
+
+    def test_patch_with_multiple_patches_revert_and_commit(self) -> None:
+        """Testing JujutsuPatcher with multiple patches, revert and commit"""
+        client = self.build_client()
+
+        old_parent = client._get_change_id('@-')
+
+        patcher = client.get_patcher(
+            revert=True,
+            patches=[
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,4 +6,7 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'+quidve dolens, regina deum tot volvere casus\n'
+                    b'+insignem pietate virum, tot adire labores\n'
+                    b'+impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                )),
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                    b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -1,6 +1,4 @@\n'
+                    b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'-ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'-ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b' Italiam, fato profugus, Laviniaque venit\n'
+                    b' litora, multum ille et terris iactatus et alto\n'
+                    b' vi superum saevae memorem Iunonis ob iram;\n'
+                )),
+            ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(
+                fp.read(),
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'Italiam, fato profugus, Laviniaque venit\n'
+                b'litora, multum ille et terris iactatus et alto\n'
+                b'vi superum saevae memorem Iunonis ob iram;\n'
+                b'multa quoque et bello passus, dum conderet urbem,\n'
+                b'inferretque deos Latio, genus unde Latinum,\n'
+                b'Albanique patres, atque altae moenia Romae.\n'
+                b'Musa, mihi causas memora, quo numine laeso,\n'
+                b'\n')
+
+        greatgrandparent = client._get_change_id('@---')
+        grandparent = client._get_change_id('@--')
+        parent = client._get_change_id('@-')
+
+        self.assertEqual(greatgrandparent, old_parent)
+        self.assertEqual(self._get_description(grandparent),
+                         '[Revert] [2/2] Test message')
+        self.assertEqual(self._get_description(parent),
+                         '[Revert] [1/2] Test message')
+
+    def test_patch_with_multiple_patches_squash_revert_and_commit(
+        self,
+    ) -> None:
+        """Testing JujutsuPatcher with multiple patches, squash, revert and
+        commit
+        """
+        client = self.build_client()
+
+        old_parent = client._get_change_id('@-')
+
+        patcher = client.get_patcher(
+            revert=True,
+            squash=True,
+            patches=[
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 634b3e8ff85bada6f928841a9f2c505560840b3a..'
+                    b'5e98e9540e1b741b5be24fcb33c40c1c8069c1fb 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -6,4 +6,7 @@ multa quoque et bello passus, '
+                    b'dum conderet urbem,\n'
+                    b' inferretque deos Latio, genus unde Latinum,\n'
+                    b' Albanique patres, atque altae moenia Romae.\n'
+                    b' Musa, mihi causas memora, quo numine laeso,\n'
+                    b'+quidve dolens, regina deum tot volvere casus\n'
+                    b'+insignem pietate virum, tot adire labores\n'
+                    b'+impulerit. Tantaene animis caelestibus irae?\n'
+                    b' \n'
+                )),
+                Patch(content=(
+                    b'diff --git a/foo.txt b/foo.txt\n'
+                    b'index 5e98e9540e1b741b5be24fcb33c40c1c8069c1fb..'
+                    b'e619c1387f5feb91f0ca83194650bfe4f6c2e347 100644\n'
+                    b'--- a/foo.txt\n'
+                    b'+++ b/foo.txt\n'
+                    b'@@ -1,6 +1,4 @@\n'
+                    b' ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'-ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b'-ARMA virumque cano, Troiae qui primus ab oris\n'
+                    b' Italiam, fato profugus, Laviniaque venit\n'
+                    b' litora, multum ille et terris iactatus et alto\n'
+                    b' vi superum saevae memorem Iunonis ob iram;\n'
+                )),
+            ])
+        patcher.prepare_for_commit(
+            default_author=PatchAuthor(full_name='Test User',
+                                       email='test@example.com'),
+            default_message='Test message')
+
+        results = list(patcher.patch())
+        self.assertEqual(len(results), 2)
+
+        result = results[0]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        result = results[1]
+        self.assertTrue(result.success)
+        self.assertIsNotNone(result.patch)
+
+        with open('foo.txt', 'rb') as fp:
+            self.assertEqual(
+                fp.read(),
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'ARMA virumque cano, Troiae qui primus ab oris\n'
+                b'Italiam, fato profugus, Laviniaque venit\n'
+                b'litora, multum ille et terris iactatus et alto\n'
+                b'vi superum saevae memorem Iunonis ob iram;\n'
+                b'multa quoque et bello passus, dum conderet urbem,\n'
+                b'inferretque deos Latio, genus unde Latinum,\n'
+                b'Albanique patres, atque altae moenia Romae.\n'
+                b'Musa, mihi causas memora, quo numine laeso,\n'
+                b'\n')
+
+        grandparent = client._get_change_id('@--')
+        parent = client._get_change_id('@-')
+
+        self.assertEqual(grandparent, old_parent)
+        self.assertEqual(self._get_description(parent),
+                         '[Revert] Test message')
+
+    def _get_description(
+        self,
+        change: str,
+    ) -> str:
+        """Get the description for a given change.
+
+        Args:
+            change (str):
+                The ID of the change (or a revset) to get the description for.
+
+        Returns:
+            str:
+            The description of the change(s).
+        """
+        return (
+            run_process(['jj', 'log', '-r', change, '--no-graph', '-T',
+                         'description'])
+            .stdout
+            .read()
+            .strip()
+        )
diff --git a/rbtools/clients/tests/test_scmclient_registry.py b/rbtools/clients/tests/test_scmclient_registry.py
index 98e61df7c3ac25ed3da037ec8f832596c8db8bd4..44daad4ff4972f0d0aad8978304ba078a04eb4b2 100644
--- a/rbtools/clients/tests/test_scmclient_registry.py
+++ b/rbtools/clients/tests/test_scmclient_registry.py
@@ -4,6 +4,8 @@ Version Added:
     4.0
 """
 
+from __future__ import annotations
+
 import re
 import sys
 
@@ -23,6 +25,7 @@ from rbtools.clients.clearcase import ClearCaseClient
 from rbtools.clients.cvs import CVSClient
 from rbtools.clients.errors import SCMClientNotFoundError
 from rbtools.clients.git import GitClient
+from rbtools.clients.jujutsu import JujutsuClient
 from rbtools.clients.mercurial import MercurialClient
 from rbtools.clients.perforce import PerforceClient
 from rbtools.clients.plastic import PlasticClient
@@ -43,7 +46,7 @@ class MySCMClient2(BaseSCMClient):
 class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
     """Unit tests for SCMClientRegistry."""
 
-    def test_init(self):
+    def test_init(self) -> None:
         """Testing SCMClientRegistry.__init__"""
         registry = SCMClientRegistry()
 
@@ -51,7 +54,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertFalse(registry._builtin_loaded)
         self.assertFalse(registry._entrypoints_loaded)
 
-    def test_iter(self):
+    def test_iter(self) -> None:
         """Testing SCMClientRegistry.__iter__"""
         registry = SCMClientRegistry()
 
@@ -71,6 +74,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
                 ClearCaseClient,
                 CVSClient,
                 GitClient,
+                JujutsuClient,
                 MercurialClient,
                 PerforceClient,
                 PlasticClient,
@@ -84,7 +88,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertTrue(registry._builtin_loaded)
         self.assertTrue(registry._entrypoints_loaded)
 
-    def test_get_with_builtin(self):
+    def test_get_with_builtin(self) -> None:
         """Testing SCMClientRegistry.get with built-in SCMClient"""
         registry = SCMClientRegistry()
 
@@ -92,7 +96,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertTrue(registry._builtin_loaded)
         self.assertFalse(registry._entrypoints_loaded)
 
-    def test_get_with_entrypoint(self):
+    def test_get_with_entrypoint(self) -> None:
         """Testing SCMClientRegistry.get with entry point SCMClient"""
         registry = SCMClientRegistry()
 
@@ -106,7 +110,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertTrue(registry._builtin_loaded)
         self.assertTrue(registry._entrypoints_loaded)
 
-    def test_get_with_entrypoint_and_missing(self):
+    def test_get_with_entrypoint_and_missing(self) -> None:
         """Testing SCMClientRegistry.get with entry point SCMClient missing"""
         registry = SCMClientRegistry()
 
@@ -127,7 +131,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertTrue(registry._builtin_loaded)
         self.assertTrue(registry._entrypoints_loaded)
 
-    def test_register(self):
+    def test_register(self) -> None:
         """Testing SCMClientRegistry.register"""
         registry = SCMClientRegistry()
         registry.register(MySCMClient1)
@@ -143,6 +147,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
                 ClearCaseClient,
                 CVSClient,
                 GitClient,
+                JujutsuClient,
                 MercurialClient,
                 PerforceClient,
                 PlasticClient,
@@ -152,7 +157,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
                 MySCMClient1,
             ])
 
-    def test_register_with_already_registered(self):
+    def test_register_with_already_registered(self) -> None:
         """Testing SCMClientRegistry.register with class already registered"""
         registry = SCMClientRegistry()
 
@@ -165,7 +170,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertFalse(registry._entrypoints_loaded)
         self.assertNotIn(MySCMClient1, registry)
 
-    def test_register_with_id_already_used(self):
+    def test_register_with_id_already_used(self) -> None:
         """Testing SCMClientRegistry.register with ID already used"""
         class MyGitClient(BaseSCMClient):
             scmclient_id = 'git'
@@ -184,7 +189,7 @@ class SCMClientRegistryTests(kgb.SpyAgency, TestCase):
         self.assertFalse(registry._entrypoints_loaded)
         self.assertNotIn(MySCMClient1, registry)
 
-    def _add_fake_entrypoints(self, entrypoints):
+    def _add_fake_entrypoints(self, entrypoints) -> None:
         self.spy_on(entry_points, op=kgb.SpyOpMatchAny([
             {
                 'args': (),
