Repository: VirtusLab/git-machete
Pull request #128 (2021-05-24 19:34:03)

Add support for 0-or-more downstream branches in slide_out
Updates the core `slide_out` interface to add support when the "tail" of the slide-out branch set has any number of downstream branches. 

This provides slide-out support for trees of the structure...

```
main
|
o-slide_me_out
    |
    o-child_a
    |
    o-child_b
```

...as may often occur when multiple feature branches share a common bugfix.

Refactors and expands the functional tests to include this feature.

This does *not* update any of the existing auto-detection of slide-able branches to these cases.

----

Potential finishing items:

- [x] Update docs.
- [x] Update changelog.


Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 2127d83..e3690bd 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,9 @@
 # Release notes
 
+## New in git-machete 3.1.2
+
+- improved: slide-out can target branches with any number of downstream (child) branches
+
 ## New in git-machete 3.1.1
 
 - fixed: `add` without `--onto` crashing when the current branch is not managed
diff --git a/git_machete/__init__.py b/git_machete/__init__.py
index 726691b..f71b21a 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.1.1'
+__version__ = '3.1.2'
diff --git a/git_machete/cmd.py b/git_machete/cmd.py
index 382eb29..d20407a 100644
--- a/git_machete/cmd.py
+++ b/git_machete/cmd.py
@@ -1978,53 +1978,66 @@ def run_post_slide_out_hook(cli_ctxt: CommandLineContext, new_upstream: str, sli
 
 
 def slide_out(cli_ctxt: CommandLineContext, branches_to_slide_out: List[str]) -> None:
+    # Verify that all branches exist, are managed, and have an upstream.
     for b in branches_to_slide_out:
         expect_in_managed_branches(b)
         new_upstream = up_branch.get(b)
         if not new_upstream:
             raise MacheteException(f"No upstream branch defined for `{b}`, cannot slide out")
-        dbs = down_branches.get(b)
+
+    # Verify that all "interior" slide-out branches have a single downstream pointing to the next slide-out
+    for bu, bd in zip(branches_to_slide_out[:-1], branches_to_slide_out[1:]):
+        dbs = down_branches.get(bu)
         if not dbs or len(dbs) == 0:
-            raise MacheteException(f"No downstream branch defined for `{b}`, cannot slide out")
+            raise MacheteException(f"No downstream branch defined for `{bu}`, cannot slide out")
         elif len(dbs) > 1:
             flat_dbs = ", ".join(f"`{x}`" for x in dbs)
-            raise MacheteException(f"Multiple downstream branches defined for `{b}`: {flat_dbs}; cannot slide out")
+            raise MacheteException(f"Multiple downstream branches defined for `{bu}`: {flat_dbs}; cannot slide out")
+        elif dbs != [bd]:
+            raise MacheteException(f"'{bd}' is not downstream of '{bu}', cannot slide out")
 
-    for bu, bd in zip(branches_to_slide_out[:-1], branches_to_slide_out[1:]):
         if up_branch[bd] != bu:
             raise MacheteException(f"`{bu}` is not upstream of `{bd}`, cannot slide out")
 
+    # Get new branches
     new_upstream = up_branch[branches_to_slide_out[0]]
-    new_downstream = down_branches[branches_to_slide_out[-1]][0]
+    new_downstreams = down_branches.get(branches_to_slide_out[-1], [])
+
+    # Remove the slide-out branches from the tree
     for b in branches_to_slide_out:
         up_branch[b] = None
         down_branches[b] = None
+    down_branches[new_upstream] = [b for b in down_branches[new_upstream] if b != branches_to_slide_out[0]]
 
-    go(cli_ctxt, new_downstream)
-    up_branch[new_downstream] = new_upstream
-    down_branches[new_upstream] = [(new_downstream if x == branches_to_slide_out[0] else x) for x in down_branches[new_upstream]]
+    # Reconnect the downstreams to the new upstream in the tree
+    for new_downstream in new_downstreams:
+        up_branch[new_downstream] = new_upstream
+        down_branches[new_upstream].append(new_downstream)
+
+    # Update definition, fire post-hook, and perform the branch update
     save_definition_file()
-    run_post_slide_out_hook(cli_ctxt, new_upstream, branches_to_slide_out[-1], [new_downstream])
-    if cli_ctxt.opt_merge:
-        print(f"Merging {bold(new_upstream)} into {bold(new_downstream)}...")
-        merge(cli_ctxt, new_upstream, new_downstream)
-    else:
-        print(f"Rebasing {bold(new_downstream)} onto {bold(new_upstream)}...")
-        rebase(cli_ctxt, f"refs/heads/{new_upstream}", cli_ctxt.opt_down_fork_point or fork_point(cli_ctxt, new_downstream, use_overrides=True), new_downstream)
+    run_post_slide_out_hook(cli_ctxt, new_upstream, branches_to_slide_out[-1], new_downstreams)
+
+    go(cli_ctxt, new_upstream)
+    for new_downstream in new_downstreams:
+        go(cli_ctxt, new_downstream)
+        if cli_ctxt.opt_merge:
+            print(f"Merging {bold(new_upstream)} into {bold(new_downstream)}...")
+            merge(cli_ctxt, new_upstream, new_downstream)
+        else:
+            print(f"Rebasing {bold(new_downstream)} onto {bold(new_upstream)}...")
+            rebase(cli_ctxt, f"refs/heads/{new_upstream}", cli_ctxt.opt_down_fork_point or fork_point(cli_ctxt, new_downstream, use_overrides=True), new_downstream)
 
 
 def slidable() -> List[str]:
-    return [b for b in managed_branches if b in up_branch and b in down_branches and len(down_branches[b]) == 1]
+    return [b for b in managed_branches if b in up_branch]
 
 
 def slidable_after(b: str) -> List[str]:
     if b in up_branch:
         dbs = down_branches.get(b)
         if dbs and len(dbs) == 1:
-            d = dbs[0]
-            ddbs = down_branches.get(d)
-            if ddbs and len(ddbs) == 1:
-                return [d]
+            return dbs
     return []
 
 
@@ -2628,7 +2641,7 @@ def usage(c: str = None) -> None:
         "log": "Log the part of history specific to the given branch",
         "reapply": "Rebase the current branch onto its computed fork point",
         "show": "Show name(s) of the branch(es) relative to the position of the current branch, accepts down/first/last/next/root/prev/up argument",
-        "slide-out": "Slide out the current branch and sync its downstream (child) branch with its upstream (parent) branch via rebase or merge",
+        "slide-out": "Slide out the current branch and sync its downstream (child) branches with its upstream (parent) branch via rebase or merge",
         "squash": "Squash the unique history of the current branch into a single commit",
         "status": "Display formatted tree of branch dependencies, including info on their sync with upstream branch and with remote",
         "traverse": "Walk through the tree of branch dependencies and rebase, merge, slide out, push and/or pull each branch one by one",
@@ -2927,7 +2940,7 @@ def usage(c: str = None) -> None:
                   or in case of multiple branches being slid out - the lowest slid out branch;
                 * <b><new-downstreams></b> are all the following (possibly zero) parameters,
                   which correspond to all original downstreams of <lowest-slid-out-branch>, now reattached as the downstreams of <new-upstream>.
-                  Note that this is guaranteed to be exactly one branch in case of `slide-out` (but no guarantees exist in case of `advance` or `traverse`).
+                  Note that this may be zero, one, or multiple branches.
 
                 Note: the hook, if present, is executed:
                 * zero or once during a `advance` execution (depending on whether the slide-out has been confirmed or not),
@@ -2935,7 +2948,7 @@ def usage(c: str = None) -> None:
                 * zero or more times during `traverse` (every time a slide-out operation is confirmed).
 
                 If the hook returns a non-zero exit code, then the execution of the command is aborted,
-                i.e. `slide-out` won't attempt rebase of the new downstream branch and `traverse` won't continue the traversal.
+                i.e. `slide-out` won't attempt rebase of the new downstream branches and `traverse` won't continue the traversal.
                 In case of `advance` there is no difference (other than exit code of the entire `advance` command being non-zero),
                 since slide-out is the last operation that happens within `advance`.
                 Note that non-zero exit code of the hook doesn't cancel the effects of slide-out itself, only the subsequent operations.
@@ -2984,7 +2997,7 @@ def usage(c: str = None) -> None:
             Lists all branches that fall into one of the specified categories:
             * `addable`: all branches (local or remote) than can be added to the definition file,
             * `managed`: all branches that appear in the definition file,
-            * `slidable`: all managed branches that have exactly one upstream and one downstream (i.e. the ones that can be slid out with `slide-out` command),
+            * `slidable`: all managed branches that have an upstream and can be slid out with `slide-out` command
             * `slidable-after <branch>`: the downstream branch of the <branch>, if it exists and is the only downstream of <branch> (i.e. the one that can be slid out immediately following <branch>),
             * `unmanaged`: all local branches that don't appear in the definition file,
             * `with-overridden-fork-point`: all local branches that have a fork point override set up (even if this override does not affect the location of their fork point anymore).
@@ -3033,14 +3046,13 @@ def usage(c: str = None) -> None:
             <b>Usage: git machete slide-out [-d|--down-fork-point=<down-fork-point-commit>] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] <branch> [<branch> [<branch> ...]]</b>
 
             Removes the given branch (or multiple branches) from the branch tree definition.
-            Then synchronizes the downstream (child) branch of the last specified branch on the top of the upstream (parent) branch of the first specified branch.
+            Then synchronizes the downstream (child) branches of the last specified branch on the top of the upstream (parent) branch of the first specified branch.
             Sync is performed either by rebase (default) or by merge (if `--merge` option passed).
 
             The most common use is to slide out a single branch whose upstream was a `develop`/`master` branch and that has been recently merged.
 
             Since this tool is designed to perform only one single rebase/merge at the end, provided branches must form a chain, i.e. all of the following conditions must be met:
-            * for i=1..N-1, (i+1)-th branch must be a downstream (child) branch of the i-th branch,
-            * all provided branches (including N-th branch) must have exactly one downstream branch,
+            * for i=1..N-1, (i+1)-th branch must be the only downstream (child) branch of the i-th branch,
             * all provided branches must have an upstream branch (so, in other words, roots of branch dependency tree cannot be slid out).
 
             For example, let's assume the following dependency tree:
@@ -3050,6 +3062,7 @@ def usage(c: str = None) -> None:
                       block-cancel-order
                           change-table
                               drop-location-type
+                          add-notification
             </dim>
             And now let's assume that `adjust-reads-prec` and later `block-cancel-order` were merged to develop.
             After running `git machete slide-out adjust-reads-prec block-cancel-order` the tree will be reduced to:
@@ -3057,14 +3070,17 @@ def usage(c: str = None) -> None:
               develop
                   change-table
                       drop-location-type
+                  add-notification
             </dim>
-            and `change-table` will be rebased onto develop (fork point for this rebase is configurable, see `-d` option below).
+            and `change-table` and `add-notification` will be rebased onto develop (fork point for this rebase is configurable, see `-d` option below).
 
             Note: This command doesn't delete any branches from git, just removes them from the tree of branch dependencies.
 
             <b>Options:</b>
-              <b>-d, --down-fork-point=<down-fork-point-commit></b>    If updating by rebase, specifies the alternative fork point commit after which the rebased part of history of the downstream branch is meant to start.
-                                                                Not allowed if updating by merge. See also doc for `--fork-point` option in `git machete help reapply` and `git machete help update`.
+              <b>-d, --down-fork-point=<down-fork-point-commit></b>    If updating by rebase, specifies the alternative fork point for downstream branches for the operation.
+                                                                `git machete fork-point` overrides for downstream branches are recommended over use of this option.
+                                                                See also doc for `--fork-point` option in `git machete help reapply` and `git machete help update`.
+                                                                Not allowed if updating by merge.
 
               <b>-M, --merge</b>                                       Update the downstream branch by merge rather than by rebase.
 


Test patch:
diff --git a/git_machete/tests/functional/test_machete.py b/git_machete/tests/functional/test_machete.py
index 05e1037..9265c29 100644
--- a/git_machete/tests/functional/test_machete.py
+++ b/git_machete/tests/functional/test_machete.py
@@ -4,193 +4,75 @@ import random
 import re
 import string
 import sys
+import textwrap
 import time
 import unittest
+from typing import Iterable
 
 from git_machete import cmd
 
 
 class SandboxSetup:
-
     def __init__(self) -> None:
         self.file_dir = os.path.dirname(os.path.abspath(__file__))
-        self.remote_path = os.popen('mktemp -d').read().strip()
-        self.sandbox_path = os.popen('mktemp -d').read().strip()
+        self.remote_path = os.popen("mktemp -d").read().strip()
+        self.sandbox_path = os.popen("mktemp -d").read().strip()
 
-    @staticmethod
-    def execute(command: str) -> None:
+    def execute(self, command: str) -> "SandboxSetup":
         result = os.system(command)
         assert result == 0, f"{command} returned {result}"
+        return self
 
-    def new_repo(self, *args: str) -> 'SandboxSetup':
+    def new_repo(self, *args: str) -> "SandboxSetup":
         os.chdir(args[0])
         if len(args) > 1:
             opt = args[1]
-            self.execute(f'git init {opt}')
+            self.execute(f"git init {opt}")
         else:
-            self.execute('git init')
+            self.execute("git init")
         return self
 
-    def new_branch(self, branch_name: str) -> 'SandboxSetup':
-        self.execute(f'git checkout -q -b {branch_name}')
+    def new_branch(self, branch_name: str) -> "SandboxSetup":
+        self.execute(f"git checkout -q -b {branch_name}")
         return self
 
-    def check_out(self, branch: str) -> 'SandboxSetup':
-        self.execute(f'git checkout -q {branch}')
+    def check_out(self, branch: str) -> "SandboxSetup":
+        self.execute(f"git checkout -q {branch}")
         return self
 
-    def commit(self, message: str) -> 'SandboxSetup':
-        f = '%s.txt' % "".join(random.choice(string.ascii_letters) for _ in range(20))
-        self.execute(f'touch {f}')
-        self.execute(f'git add {f}')
+    def commit(self, message: str) -> "SandboxSetup":
+        f = "%s.txt" % "".join(random.choice(string.ascii_letters) for _ in range(20))
+        self.execute(f"touch {f}")
+        self.execute(f"git add {f}")
         self.execute(f'git commit -q -m "{message}"')
         return self
 
-    def commit_amend(self, message: str) -> 'SandboxSetup':
+    def commit_amend(self, message: str) -> "SandboxSetup":
         self.execute(f'git commit -q --amend -m "{message}"')
         return self
 
-    def push(self) -> 'SandboxSetup':
-        branch = os.popen('git symbolic-ref --short HEAD').read()
-        self.execute(f'git push -q -u origin {branch}')
+    def push(self) -> "SandboxSetup":
+        branch = os.popen("git symbolic-ref --short HEAD").read()
+        self.execute(f"git push -q -u origin {branch}")
         return self
 
-    def sleep(self, seconds: int) -> 'SandboxSetup':
+    def sleep(self, seconds: int) -> "SandboxSetup":
         time.sleep(seconds)
         return self
 
-    def reset_to(self, revision: str) -> 'SandboxSetup':
+    def reset_to(self, revision: str) -> "SandboxSetup":
         self.execute(f'git reset --keep "{revision}"')
         return self
 
-    def delete_branch(self, branch: str) -> 'SandboxSetup':
+    def delete_branch(self, branch: str) -> "SandboxSetup":
         self.execute(f'git branch -d "{branch}"')
         return self
 
-    def setup_sandbox(self) -> None:
-        self.new_repo(self.remote_path, '--bare')
-        self.new_repo(self.sandbox_path)
-        self.execute(f'git remote add origin {self.remote_path}')
-        self.execute('git config user.email "tester@test.com"')
-        self.execute('git config user.name "Tester Test"')
-        self.new_branch('root') \
-            .commit('root') \
-            .new_branch('develop') \
-            .commit('develop commit') \
-            .new_branch('allow-ownership-link') \
-            .commit('Allow ownership links') \
-            .push() \
-            .new_branch('build-chain') \
-            .commit('Build arbitrarily long chains') \
-            .check_out('allow-ownership-link') \
-            .commit('1st round of fixes') \
-            .check_out('develop') \
-            .commit('Other develop commit') \
-            .push() \
-            .new_branch('call-ws') \
-            .commit('Call web service') \
-            .commit('1st round of fixes') \
-            .push() \
-            .new_branch('drop-constraint') \
-            .commit('Drop unneeded SQL constraints') \
-            .check_out('call-ws') \
-            .commit('2nd round of fixes') \
-            .check_out('root') \
-            .new_branch('master') \
-            .commit('Master commit') \
-            .push() \
-            .new_branch('hotfix/add-trigger') \
-            .commit('HOTFIX Add the trigger') \
-            .push() \
-            .commit_amend('HOTFIX Add the trigger (amended)') \
-            .new_branch('ignore-trailing') \
-            .commit('Ignore trailing data') \
-            .sleep(1) \
-            .commit_amend('Ignore trailing data (amended)') \
-            .push() \
-            .reset_to('ignore-trailing@{1}') \
-            .delete_branch('root')
-
-
-Setup = SandboxSetup()
-
-
-def adapt(s: str) -> str:
-    return re.sub(r"\|\n", "| \n", s[1:])
-
-
-expected_status_1 = adapt("""
-  develop
-  |
-  x-allow-ownership-link (ahead of origin)
-  | |
-  | x-build-chain (untracked)
-  |
-  o-call-ws (ahead of origin)
-    |
-    x-drop-constraint (untracked)
-
-  master
-  |
-  o-hotfix/add-trigger (diverged from origin)
-    |
-    o-ignore-trailing * (diverged from & older than origin)
-""")
-
-expected_status_l_2 = adapt("""
-  develop
-  |
-  | Allow ownership links
-  | 1st round of fixes
-  o-allow-ownership-link
-  | |
-  | | Build arbitrarily long chains
-  | o-build-chain
-  |
-  | Call web service
-  | 1st round of fixes
-  | 2nd round of fixes
-  o-call-ws
-    |
-    | Drop unneeded SQL constraints
-    o-drop-constraint
-
-  master
-  |
-  | HOTFIX Add the trigger (amended)
-  o-hotfix/add-trigger
-    |
-    | Ignore trailing data (amended)
-    o-ignore-trailing *
-""")
-
-expected_status_l_3 = adapt("""
-  develop
-  |
-  | Allow ownership links
-  | 1st round of fixes
-  o-allow-ownership-link
-  | |
-  | | Build arbitrarily long chains
-  | o-build-chain
-  |
-  | Call web service
-  o-call-ws * (diverged from origin)
-    |
-    | Drop unneeded SQL constraints
-    x-drop-constraint
-
-  master
-  |
-  | HOTFIX Add the trigger (amended)
-  o-hotfix/add-trigger
-    |
-    | Ignore trailing data (amended)
-    o-ignore-trailing
-""")
-
 
 class MacheteTester(unittest.TestCase):
+    @staticmethod
+    def adapt(s: str) -> str:
+        return textwrap.indent(textwrap.dedent(re.sub(r"\|\n", "| \n", s[1:])), "  ")
 
     @staticmethod
     def launch_command(*args: str) -> str:
@@ -204,14 +86,296 @@ class MacheteTester(unittest.TestCase):
             sys.stdout = orig_out
         return out.getvalue()
 
+    def assert_command(self, cmd: Iterable[str], expected_result: str) -> None:
+        self.assertEqual(self.launch_command(*cmd), self.adapt(expected_result))
+
+    def setUp(self) -> None:
+        self.setup = SandboxSetup()
+
+        (
+            self.setup
+            # Create the remote and sandbox repos, chdir into sandbox repo
+            .new_repo(self.setup.remote_path, "--bare")
+            .new_repo(self.setup.sandbox_path)
+            .execute(f"git remote add origin {self.setup.remote_path}")
+            .execute('git config user.email "tester@test.com"')
+            .execute('git config user.name "Tester Test"')
+        )
+
     def test_discover_traverse_squash(self) -> None:
-        Setup.setup_sandbox()
-        self.launch_command('discover', '-y', '--roots=develop,master')
-        self.assertEqual(self.launch_command('status'), expected_status_1)
-        self.launch_command('traverse', '-Wy')
-        self.assertEqual(self.launch_command('status', '-l'), expected_status_l_2)
+        (
+            self.setup.new_branch("root")
+            .commit("root")
+            .new_branch("develop")
+            .commit("develop commit")
+            .new_branch("allow-ownership-link")
+            .commit("Allow ownership links")
+            .push()
+            .new_branch("build-chain")
+            .commit("Build arbitrarily long chains")
+            .check_out("allow-ownership-link")
+            .commit("1st round of fixes")
+            .check_out("develop")
+            .commit("Other develop commit")
+            .push()
+            .new_branch("call-ws")
+            .commit("Call web service")
+            .commit("1st round of fixes")
+            .push()
+            .new_branch("drop-constraint")
+            .commit("Drop unneeded SQL constraints")
+            .check_out("call-ws")
+            .commit("2nd round of fixes")
+            .check_out("root")
+            .new_branch("master")
+            .commit("Master commit")
+            .push()
+            .new_branch("hotfix/add-trigger")
+            .commit("HOTFIX Add the trigger")
+            .push()
+            .commit_amend("HOTFIX Add the trigger (amended)")
+            .new_branch("ignore-trailing")
+            .commit("Ignore trailing data")
+            .sleep(1)
+            .commit_amend("Ignore trailing data (amended)")
+            .push()
+            .reset_to("ignore-trailing@{1}")
+            .delete_branch("root")
+        )
+
+        self.launch_command("discover", "-y", "--roots=develop,master")
+        self.assert_command(
+            ["status"],
+            """
+            develop
+            |
+            x-allow-ownership-link (ahead of origin)
+            | |
+            | x-build-chain (untracked)
+            |
+            o-call-ws (ahead of origin)
+              |
+              x-drop-constraint (untracked)
+
+            master
+            |
+            o-hotfix/add-trigger (diverged from origin)
+              |
+              o-ignore-trailing * (diverged from & older than origin)
+            """,
+        )
+
+        self.launch_command("traverse", "-Wy")
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | Allow ownership links
+            | 1st round of fixes
+            o-allow-ownership-link
+            | |
+            | | Build arbitrarily long chains
+            | o-build-chain
+            |
+            | Call web service
+            | 1st round of fixes
+            | 2nd round of fixes
+            o-call-ws
+              |
+              | Drop unneeded SQL constraints
+              o-drop-constraint
+
+            master
+            |
+            | HOTFIX Add the trigger (amended)
+            o-hotfix/add-trigger
+              |
+              | Ignore trailing data (amended)
+              o-ignore-trailing *
+            """,
+        )
+
         # Go from ignore-trailing to call-ws which has >1 commit to be squashed
         for _ in range(4):
-            self.launch_command('go', 'prev')
-        self.launch_command('squash', '-v')
-        self.assertEqual(self.launch_command('status', '-l'), expected_status_l_3)
+            self.launch_command("go", "prev")
+        self.launch_command("squash", "-v")
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | Allow ownership links
+            | 1st round of fixes
+            o-allow-ownership-link
+            | |
+            | | Build arbitrarily long chains
+            | o-build-chain
+            |
+            | Call web service
+            o-call-ws * (diverged from origin)
+              |
+              | Drop unneeded SQL constraints
+              x-drop-constraint
+
+            master
+            |
+            | HOTFIX Add the trigger (amended)
+            o-hotfix/add-trigger
+              |
+              | Ignore trailing data (amended)
+              o-ignore-trailing
+            """,
+        )
+
+    def test_slide_out(self) -> None:
+        (
+            self.setup.new_branch("develop")
+            .commit("develop commit")
+            .push()
+            .new_branch("slide_root")
+            .commit("slide_root_1")
+            .push()
+            .check_out("slide_root")
+            .new_branch("child_a")
+            .commit("child_a_1")
+            .push()
+            .check_out("slide_root")
+            .new_branch("child_b")
+            .commit("child_b_1")
+            .push()
+            .check_out("child_b")
+            .new_branch("child_c")
+            .commit("child_c_1")
+            .push()
+            .new_branch("child_d")
+            .commit("child_d_1")
+            .push()
+        )
+
+        self.launch_command("discover", "-y", "--roots=develop")
+
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | slide_root_1
+            o-slide_root
+              |
+              | child_a_1
+              o-child_a
+              |
+              | child_b_1
+              o-child_b
+                |
+                | child_c_1
+                o-child_c
+                  |
+                  | child_d_1
+                  o-child_d *
+            """,
+        )
+
+        # Slide-out a single interior branch with one downstream. (child_c)
+        # This rebases the single downstream onto the new upstream. (child_b -> child_d)
+
+        self.launch_command("go", "up")
+        self.launch_command("slide-out", "-n")
+
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | slide_root_1
+            o-slide_root
+              |
+              | child_a_1
+              o-child_a
+              |
+              | child_b_1
+              o-child_b
+                |
+                | child_d_1
+                o-child_d * (diverged from origin)
+            """,
+        )
+
+        # Slide-out an interior branch with multiple downstreams. (slide_root)
+        # This rebases all the downstreams onto the new upstream. (develop -> [child_a, child_b])
+        self.launch_command("traverse", "-Wy")
+        self.launch_command("go", "up")
+        self.launch_command("go", "up")
+
+        self.assert_command(
+            ["status", "-l"],
+            """
+                develop
+                |
+                | slide_root_1
+                o-slide_root *
+                  |
+                  | child_a_1
+                  o-child_a
+                  |
+                  | child_b_1
+                  o-child_b
+                    |
+                    | child_d_1
+                    o-child_d
+                """,
+        )
+
+        self.launch_command("slide-out", "-n")
+
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | child_a_1
+            o-child_a (diverged from origin)
+            |
+            | child_b_1
+            o-child_b * (diverged from origin)
+              |
+              | child_d_1
+              x-child_d
+            """,
+        )
+
+        self.launch_command("traverse", "-Wy")
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | child_a_1
+            o-child_a
+            |
+            | child_b_1
+            o-child_b *
+              |
+              | child_d_1
+              o-child_d
+            """,
+        )
+
+        # Slide-out a terminal branch. (child_d)
+        # This just slices the branch off the tree.
+        self.launch_command("go", "down")
+        self.launch_command("slide-out", "-n")
+
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | child_a_1
+            o-child_a
+            |
+            | child_b_1
+            o-child_b *
+            """,
+        )