diff --git a/mercurial/cmd_impls/__init__.py b/mercurial/cmd_impls/__init__.py
new file mode 100644
diff --git a/mercurial/cmd_impls/graft.py b/mercurial/cmd_impls/graft.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2fc0a91faca387a9e201fe96c32c21639fee09c_bWVyY3VyaWFsL2NtZF9pbXBscy9ncmFmdC5weQ==
--- /dev/null
+++ b/mercurial/cmd_impls/graft.py
@@ -0,0 +1,284 @@
+# graft.py - implementation of the graft command
+
+from ..i18n import _
+
+from .. import cmdutil, error, logcmdutil, merge as mergemod, state as statemod
+
+
+def cmd_graft(ui, repo, *revs, **opts):
+    """implement the graft command as defined in mercuria/commands.py"""
+    if revs and opts.get('rev'):
+        ui.warn(
+            _(
+                b'warning: inconsistent use of --rev might give unexpected '
+                b'revision ordering!\n'
+            )
+        )
+
+    revs = list(revs)
+    revs.extend(opts.get('rev'))
+    # a dict of data to be stored in state file
+    statedata = {}
+    # list of new nodes created by ongoing graft
+    statedata[b'newnodes'] = []
+
+    cmdutil.resolve_commit_options(ui, opts)
+
+    editor = cmdutil.getcommiteditor(editform=b'graft', **opts)
+
+    cmdutil.check_at_most_one_arg(opts, 'abort', 'stop', 'continue')
+
+    cont = False
+    if opts.get('no_commit'):
+        cmdutil.check_incompatible_arguments(
+            opts,
+            'no_commit',
+            ['edit', 'currentuser', 'currentdate', 'log'],
+        )
+
+    graftstate = statemod.cmdstate(repo, b'graftstate')
+
+    if opts.get('stop'):
+        cmdutil.check_incompatible_arguments(
+            opts,
+            'stop',
+            [
+                'edit',
+                'log',
+                'user',
+                'date',
+                'currentdate',
+                'currentuser',
+                'rev',
+            ],
+        )
+        return _stopgraft(ui, repo, graftstate)
+    elif opts.get('abort'):
+        cmdutil.check_incompatible_arguments(
+            opts,
+            'abort',
+            [
+                'edit',
+                'log',
+                'user',
+                'date',
+                'currentdate',
+                'currentuser',
+                'rev',
+            ],
+        )
+        return cmdutil.abortgraft(ui, repo, graftstate)
+    elif opts.get('continue'):
+        cont = True
+        if revs:
+            raise error.InputError(_(b"can't specify --continue and revisions"))
+        # read in unfinished revisions
+        if graftstate.exists():
+            statedata = cmdutil.readgraftstate(repo, graftstate)
+            if statedata.get(b'date'):
+                opts['date'] = statedata[b'date']
+            if statedata.get(b'user'):
+                opts['user'] = statedata[b'user']
+            if statedata.get(b'log'):
+                opts['log'] = True
+            if statedata.get(b'no_commit'):
+                opts['no_commit'] = statedata.get(b'no_commit')
+            if statedata.get(b'base'):
+                opts['base'] = statedata.get(b'base')
+            nodes = statedata[b'nodes']
+            revs = [repo[node].rev() for node in nodes]
+        else:
+            cmdutil.wrongtooltocontinue(repo, _(b'graft'))
+    else:
+        if not revs:
+            raise error.InputError(_(b'no revisions specified'))
+        cmdutil.checkunfinished(repo)
+        cmdutil.bailifchanged(repo)
+        revs = logcmdutil.revrange(repo, revs)
+
+    skipped = set()
+    basectx = None
+    if opts.get('base'):
+        basectx = logcmdutil.revsingle(repo, opts['base'], None)
+    if basectx is None:
+        # check for merges
+        for rev in repo.revs(b'%ld and merge()', revs):
+            ui.warn(_(b'skipping ungraftable merge revision %d\n') % rev)
+            skipped.add(rev)
+    revs = [r for r in revs if r not in skipped]
+    if not revs:
+        return -1
+    if basectx is not None and len(revs) != 1:
+        raise error.InputError(_(b'only one revision allowed with --base '))
+
+    # Don't check in the --continue case, in effect retaining --force across
+    # --continues. That's because without --force, any revisions we decided to
+    # skip would have been filtered out here, so they wouldn't have made their
+    # way to the graftstate. With --force, any revisions we would have otherwise
+    # skipped would not have been filtered out, and if they hadn't been applied
+    # already, they'd have been in the graftstate.
+    if not (cont or opts.get('force')) and basectx is None:
+        # check for ancestors of dest branch
+        ancestors = repo.revs(b'%ld & (::.)', revs)
+        for rev in ancestors:
+            ui.warn(_(b'skipping ancestor revision %d:%s\n') % (rev, repo[rev]))
+
+        revs = [r for r in revs if r not in ancestors]
+
+        if not revs:
+            return -1
+
+        # analyze revs for earlier grafts
+        ids = {}
+        for ctx in repo.set(b"%ld", revs):
+            ids[ctx.hex()] = ctx.rev()
+            n = ctx.extra().get(b'source')
+            if n:
+                ids[n] = ctx.rev()
+
+        # check ancestors for earlier grafts
+        ui.debug(b'scanning for duplicate grafts\n')
+
+        # The only changesets we can be sure doesn't contain grafts of any
+        # revs, are the ones that are common ancestors of *all* revs:
+        for rev in repo.revs(b'only(%d,ancestor(%ld))', repo[b'.'].rev(), revs):
+            ctx = repo[rev]
+            n = ctx.extra().get(b'source')
+            if n in ids:
+                try:
+                    r = repo[n].rev()
+                except error.RepoLookupError:
+                    r = None
+                if r in revs:
+                    ui.warn(
+                        _(
+                            b'skipping revision %d:%s '
+                            b'(already grafted to %d:%s)\n'
+                        )
+                        % (r, repo[r], rev, ctx)
+                    )
+                    revs.remove(r)
+                elif ids[n] in revs:
+                    if r is None:
+                        ui.warn(
+                            _(
+                                b'skipping already grafted revision %d:%s '
+                                b'(%d:%s also has unknown origin %s)\n'
+                            )
+                            % (ids[n], repo[ids[n]], rev, ctx, n[:12])
+                        )
+                    else:
+                        ui.warn(
+                            _(
+                                b'skipping already grafted revision %d:%s '
+                                b'(%d:%s also has origin %d:%s)\n'
+                            )
+                            % (ids[n], repo[ids[n]], rev, ctx, r, n[:12])
+                        )
+                    revs.remove(ids[n])
+            elif ctx.hex() in ids:
+                r = ids[ctx.hex()]
+                if r in revs:
+                    ui.warn(
+                        _(
+                            b'skipping already grafted revision %d:%s '
+                            b'(was grafted from %d:%s)\n'
+                        )
+                        % (r, repo[r], rev, ctx)
+                    )
+                    revs.remove(r)
+        if not revs:
+            return -1
+
+    if opts.get('no_commit'):
+        statedata[b'no_commit'] = True
+    if opts.get('base'):
+        statedata[b'base'] = opts['base']
+    for pos, ctx in enumerate(repo.set(b"%ld", revs)):
+        desc = b'%d:%s "%s"' % (
+            ctx.rev(),
+            ctx,
+            ctx.description().split(b'\n', 1)[0],
+        )
+        names = repo.nodetags(ctx.node()) + repo.nodebookmarks(ctx.node())
+        if names:
+            desc += b' (%s)' % b' '.join(names)
+        ui.status(_(b'grafting %s\n') % desc)
+        if opts.get('dry_run'):
+            continue
+
+        source = ctx.extra().get(b'source')
+        extra = {}
+        if source:
+            extra[b'source'] = source
+            extra[b'intermediate-source'] = ctx.hex()
+        else:
+            extra[b'source'] = ctx.hex()
+        user = ctx.user()
+        if opts.get('user'):
+            user = opts['user']
+            statedata[b'user'] = user
+        date = ctx.date()
+        if opts.get('date'):
+            date = opts['date']
+            statedata[b'date'] = date
+        message = ctx.description()
+        if opts.get('log'):
+            message += b'\n(grafted from %s)' % ctx.hex()
+            statedata[b'log'] = True
+
+        # we don't merge the first commit when continuing
+        if not cont:
+            # perform the graft merge with p1(rev) as 'ancestor'
+            overrides = {(b'ui', b'forcemerge'): opts.get('tool', b'')}
+            base = ctx.p1() if basectx is None else basectx
+            with ui.configoverride(overrides, b'graft'):
+                stats = mergemod.graft(
+                    repo, ctx, base, [b'local', b'graft', b'parent of graft']
+                )
+            # report any conflicts
+            if stats.unresolvedcount > 0:
+                # write out state for --continue
+                nodes = [repo[rev].hex() for rev in revs[pos:]]
+                statedata[b'nodes'] = nodes
+                stateversion = 1
+                graftstate.save(stateversion, statedata)
+                ui.error(_(b"abort: unresolved conflicts, can't continue\n"))
+                ui.error(_(b"(use 'hg resolve' and 'hg graft --continue')\n"))
+                return 1
+        else:
+            cont = False
+
+        # commit if --no-commit is false
+        if not opts.get('no_commit'):
+            node = repo.commit(
+                text=message, user=user, date=date, extra=extra, editor=editor
+            )
+            if node is None:
+                ui.warn(
+                    _(b'note: graft of %d:%s created no changes to commit\n')
+                    % (ctx.rev(), ctx)
+                )
+            # checking that newnodes exist because old state files won't have it
+            elif statedata.get(b'newnodes') is not None:
+                nn = statedata[b'newnodes']
+                assert isinstance(nn, list)  # list of bytes
+                nn.append(node)
+
+    # remove state when we complete successfully
+    if not opts.get('dry_run'):
+        graftstate.delete()
+
+    return 0
+
+
+def _stopgraft(ui, repo, graftstate):
+    """stop the interrupted graft"""
+    if not graftstate.exists():
+        raise error.StateError(_(b"no interrupted graft found"))
+    pctx = repo[b'.']
+    mergemod.clean_update(pctx)
+    graftstate.delete()
+    ui.status(_(b"stopped the interrupted graft\n"))
+    ui.status(_(b"working directory is now at %s\n") % pctx.hex()[:12])
+    return 0
diff --git a/mercurial/commands.py b/mercurial/commands.py
index d24731850b2e1ac45741d689491115fa836d7e49_bWVyY3VyaWFsL2NvbW1hbmRzLnB5..f2fc0a91faca387a9e201fe96c32c21639fee09c_bWVyY3VyaWFsL2NvbW1hbmRzLnB5 100644
--- a/mercurial/commands.py
+++ b/mercurial/commands.py
@@ -68,6 +68,9 @@
     vfs as vfsmod,
     wireprotoserver,
 )
+
+from .cmd_impls import graft as graft_impl
+
 from .utils import (
     dateutil,
     procutil,
@@ -3185,285 +3188,7 @@
     Returns 0 on successful completion, 1 if there are unresolved files.
     """
     with repo.wlock():
-        return _dograft(ui, repo, *revs, **opts)
-
-
-def _dograft(ui, repo, *revs, **opts):
-    if revs and opts.get('rev'):
-        ui.warn(
-            _(
-                b'warning: inconsistent use of --rev might give unexpected '
-                b'revision ordering!\n'
-            )
-        )
-
-    revs = list(revs)
-    revs.extend(opts.get('rev'))
-    # a dict of data to be stored in state file
-    statedata = {}
-    # list of new nodes created by ongoing graft
-    statedata[b'newnodes'] = []
-
-    cmdutil.resolve_commit_options(ui, opts)
-
-    editor = cmdutil.getcommiteditor(editform=b'graft', **opts)
-
-    cmdutil.check_at_most_one_arg(opts, 'abort', 'stop', 'continue')
-
-    cont = False
-    if opts.get('no_commit'):
-        cmdutil.check_incompatible_arguments(
-            opts,
-            'no_commit',
-            ['edit', 'currentuser', 'currentdate', 'log'],
-        )
-
-    graftstate = statemod.cmdstate(repo, b'graftstate')
-
-    if opts.get('stop'):
-        cmdutil.check_incompatible_arguments(
-            opts,
-            'stop',
-            [
-                'edit',
-                'log',
-                'user',
-                'date',
-                'currentdate',
-                'currentuser',
-                'rev',
-            ],
-        )
-        return _stopgraft(ui, repo, graftstate)
-    elif opts.get('abort'):
-        cmdutil.check_incompatible_arguments(
-            opts,
-            'abort',
-            [
-                'edit',
-                'log',
-                'user',
-                'date',
-                'currentdate',
-                'currentuser',
-                'rev',
-            ],
-        )
-        return cmdutil.abortgraft(ui, repo, graftstate)
-    elif opts.get('continue'):
-        cont = True
-        if revs:
-            raise error.InputError(_(b"can't specify --continue and revisions"))
-        # read in unfinished revisions
-        if graftstate.exists():
-            statedata = cmdutil.readgraftstate(repo, graftstate)
-            if statedata.get(b'date'):
-                opts['date'] = statedata[b'date']
-            if statedata.get(b'user'):
-                opts['user'] = statedata[b'user']
-            if statedata.get(b'log'):
-                opts['log'] = True
-            if statedata.get(b'no_commit'):
-                opts['no_commit'] = statedata.get(b'no_commit')
-            if statedata.get(b'base'):
-                opts['base'] = statedata.get(b'base')
-            nodes = statedata[b'nodes']
-            revs = [repo[node].rev() for node in nodes]
-        else:
-            cmdutil.wrongtooltocontinue(repo, _(b'graft'))
-    else:
-        if not revs:
-            raise error.InputError(_(b'no revisions specified'))
-        cmdutil.checkunfinished(repo)
-        cmdutil.bailifchanged(repo)
-        revs = logcmdutil.revrange(repo, revs)
-
-    skipped = set()
-    basectx = None
-    if opts.get('base'):
-        basectx = logcmdutil.revsingle(repo, opts['base'], None)
-    if basectx is None:
-        # check for merges
-        for rev in repo.revs(b'%ld and merge()', revs):
-            ui.warn(_(b'skipping ungraftable merge revision %d\n') % rev)
-            skipped.add(rev)
-    revs = [r for r in revs if r not in skipped]
-    if not revs:
-        return -1
-    if basectx is not None and len(revs) != 1:
-        raise error.InputError(_(b'only one revision allowed with --base '))
-
-    # Don't check in the --continue case, in effect retaining --force across
-    # --continues. That's because without --force, any revisions we decided to
-    # skip would have been filtered out here, so they wouldn't have made their
-    # way to the graftstate. With --force, any revisions we would have otherwise
-    # skipped would not have been filtered out, and if they hadn't been applied
-    # already, they'd have been in the graftstate.
-    if not (cont or opts.get('force')) and basectx is None:
-        # check for ancestors of dest branch
-        ancestors = repo.revs(b'%ld & (::.)', revs)
-        for rev in ancestors:
-            ui.warn(_(b'skipping ancestor revision %d:%s\n') % (rev, repo[rev]))
-
-        revs = [r for r in revs if r not in ancestors]
-
-        if not revs:
-            return -1
-
-        # analyze revs for earlier grafts
-        ids = {}
-        for ctx in repo.set(b"%ld", revs):
-            ids[ctx.hex()] = ctx.rev()
-            n = ctx.extra().get(b'source')
-            if n:
-                ids[n] = ctx.rev()
-
-        # check ancestors for earlier grafts
-        ui.debug(b'scanning for duplicate grafts\n')
-
-        # The only changesets we can be sure doesn't contain grafts of any
-        # revs, are the ones that are common ancestors of *all* revs:
-        for rev in repo.revs(b'only(%d,ancestor(%ld))', repo[b'.'].rev(), revs):
-            ctx = repo[rev]
-            n = ctx.extra().get(b'source')
-            if n in ids:
-                try:
-                    r = repo[n].rev()
-                except error.RepoLookupError:
-                    r = None
-                if r in revs:
-                    ui.warn(
-                        _(
-                            b'skipping revision %d:%s '
-                            b'(already grafted to %d:%s)\n'
-                        )
-                        % (r, repo[r], rev, ctx)
-                    )
-                    revs.remove(r)
-                elif ids[n] in revs:
-                    if r is None:
-                        ui.warn(
-                            _(
-                                b'skipping already grafted revision %d:%s '
-                                b'(%d:%s also has unknown origin %s)\n'
-                            )
-                            % (ids[n], repo[ids[n]], rev, ctx, n[:12])
-                        )
-                    else:
-                        ui.warn(
-                            _(
-                                b'skipping already grafted revision %d:%s '
-                                b'(%d:%s also has origin %d:%s)\n'
-                            )
-                            % (ids[n], repo[ids[n]], rev, ctx, r, n[:12])
-                        )
-                    revs.remove(ids[n])
-            elif ctx.hex() in ids:
-                r = ids[ctx.hex()]
-                if r in revs:
-                    ui.warn(
-                        _(
-                            b'skipping already grafted revision %d:%s '
-                            b'(was grafted from %d:%s)\n'
-                        )
-                        % (r, repo[r], rev, ctx)
-                    )
-                    revs.remove(r)
-        if not revs:
-            return -1
-
-    if opts.get('no_commit'):
-        statedata[b'no_commit'] = True
-    if opts.get('base'):
-        statedata[b'base'] = opts['base']
-    for pos, ctx in enumerate(repo.set(b"%ld", revs)):
-        desc = b'%d:%s "%s"' % (
-            ctx.rev(),
-            ctx,
-            ctx.description().split(b'\n', 1)[0],
-        )
-        names = repo.nodetags(ctx.node()) + repo.nodebookmarks(ctx.node())
-        if names:
-            desc += b' (%s)' % b' '.join(names)
-        ui.status(_(b'grafting %s\n') % desc)
-        if opts.get('dry_run'):
-            continue
-
-        source = ctx.extra().get(b'source')
-        extra = {}
-        if source:
-            extra[b'source'] = source
-            extra[b'intermediate-source'] = ctx.hex()
-        else:
-            extra[b'source'] = ctx.hex()
-        user = ctx.user()
-        if opts.get('user'):
-            user = opts['user']
-            statedata[b'user'] = user
-        date = ctx.date()
-        if opts.get('date'):
-            date = opts['date']
-            statedata[b'date'] = date
-        message = ctx.description()
-        if opts.get('log'):
-            message += b'\n(grafted from %s)' % ctx.hex()
-            statedata[b'log'] = True
-
-        # we don't merge the first commit when continuing
-        if not cont:
-            # perform the graft merge with p1(rev) as 'ancestor'
-            overrides = {(b'ui', b'forcemerge'): opts.get('tool', b'')}
-            base = ctx.p1() if basectx is None else basectx
-            with ui.configoverride(overrides, b'graft'):
-                stats = mergemod.graft(
-                    repo, ctx, base, [b'local', b'graft', b'parent of graft']
-                )
-            # report any conflicts
-            if stats.unresolvedcount > 0:
-                # write out state for --continue
-                nodes = [repo[rev].hex() for rev in revs[pos:]]
-                statedata[b'nodes'] = nodes
-                stateversion = 1
-                graftstate.save(stateversion, statedata)
-                ui.error(_(b"abort: unresolved conflicts, can't continue\n"))
-                ui.error(_(b"(use 'hg resolve' and 'hg graft --continue')\n"))
-                return 1
-        else:
-            cont = False
-
-        # commit if --no-commit is false
-        if not opts.get('no_commit'):
-            node = repo.commit(
-                text=message, user=user, date=date, extra=extra, editor=editor
-            )
-            if node is None:
-                ui.warn(
-                    _(b'note: graft of %d:%s created no changes to commit\n')
-                    % (ctx.rev(), ctx)
-                )
-            # checking that newnodes exist because old state files won't have it
-            elif statedata.get(b'newnodes') is not None:
-                nn = statedata[b'newnodes']
-                assert isinstance(nn, list)  # list of bytes
-                nn.append(node)
-
-    # remove state when we complete successfully
-    if not opts.get('dry_run'):
-        graftstate.delete()
-
-    return 0
-
-
-def _stopgraft(ui, repo, graftstate):
-    """stop the interrupted graft"""
-    if not graftstate.exists():
-        raise error.StateError(_(b"no interrupted graft found"))
-    pctx = repo[b'.']
-    mergemod.clean_update(pctx)
-    graftstate.delete()
-    ui.status(_(b"stopped the interrupted graft\n"))
-    ui.status(_(b"working directory is now at %s\n") % pctx.hex()[:12])
-    return 0
+        return graft_impl.cmd_graft(ui, repo, *revs, **opts)
 
 
 statemod.addunfinished(
diff --git a/setup.py b/setup.py
index d24731850b2e1ac45741d689491115fa836d7e49_c2V0dXAucHk=..f2fc0a91faca387a9e201fe96c32c21639fee09c_c2V0dXAucHk= 100644
--- a/setup.py
+++ b/setup.py
@@ -1358,6 +1358,7 @@
     'mercurial.admin',
     'mercurial.cext',
     'mercurial.cffi',
+    'mercurial.cmd_impls',
     'mercurial.branching',
     'mercurial.defaultrc',
     'mercurial.dirstateutils',