Repository: VirtusLab/git-machete
Pull request #1428 (2025-04-25 11:35:13)

Extract low-level git operations away from CLI parsing logic


Patch:
diff --git a/ci/checks/enforce-mocking-only-whitelisted-methods.sh b/ci/checks/enforce-mocking-only-whitelisted-methods.sh
index 7f63d49..fe5698e 100755
--- a/ci/checks/enforce-mocking-only-whitelisted-methods.sh
+++ b/ci/checks/enforce-mocking-only-whitelisted-methods.sh
@@ -7,7 +7,6 @@ self_name=$(basename "$0")
 whitelisted_methods="\
 builtins.input
 builtins.open
-git_machete.client.base.MacheteClient.is_stdout_a_tty
 git_machete.code_hosting.OrganizationAndRepository.from_url
 git_machete.git_operations.GitContext.fetch_remote
 git_machete.github.GitHubClient.MAX_PULLS_PER_PAGE_COUNT
@@ -18,6 +17,7 @@ git_machete.utils._popen_cmd
 git_machete.utils._run_cmd
 git_machete.utils.find_executable
 git_machete.utils.get_current_date
+git_machete.utils.is_stdout_a_tty
 os.path.isfile
 shutil.which
 sys.argv
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index 0fca41b..fbb15a2 100644
--- a/docs/man/git-machete.1
+++ b/docs/man/git-machete.1
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
-.TH "GIT-MACHETE" "1" "Apr 22, 2025" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Apr 25, 2025" "" "git-machete"
 .SH NAME
 git-machete \- git-machete 3.34.2
 .sp
diff --git a/git_machete/cli.py b/git_machete/cli.py
index 6f975f4..cef4cd2 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -4,7 +4,6 @@ import argparse
 import itertools
 import os
 import pkgutil
-import re
 import sys
 import textwrap
 from typing import (Any, Dict, Iterable, List, Optional, Sequence, Tuple,
@@ -32,9 +31,8 @@ from git_machete.gitlab import GitLabClient
 from .exceptions import (ExitCode, InteractionStopped, MacheteException,
                          UnderlyingGitException, UnexpectedMacheteException)
 from .generated_docs import long_docs, short_docs
-from .git_operations import (AnyBranchName, AnyRevision, GitContext,
-                             LocalBranchShortName, RemoteBranchShortName)
-from .utils import bold, excluding, fmt, underline, warn
+from .git_operations import AnyRevision, GitContext, LocalBranchShortName
+from .utils import bold, fmt, underline, warn
 
 T = TypeVar('T')
 
@@ -436,7 +434,7 @@ def create_cli_parser() -> argparse.ArgumentParser:
 def update_cli_options_using_parsed_args(
         cli_opts: git_machete.options.CommandLineOptions,
         parsed_args: argparse.Namespace) -> None:
-    # Warning: In mypy, Arguments that come from untyped functions/variables are silently treated by mypy as Any.
+    # Warning: in mypy, arguments that come from untyped functions/variables are silently treated by mypy as Any.
     # Since argparse is not typed, everything that comes from argparse.Namespace will be taken as Any :(
     # Even if we add type=LocalBranchShortName into argument parser for branch,
     # python debugger will see branch as LocalBranchShortName but mypy always will see it as Any,
@@ -456,7 +454,7 @@ def update_cli_options_using_parsed_args(
         elif opt == "as_root":
             cli_opts.opt_as_root = True
         elif opt == "branch":
-            cli_opts.opt_branch = AnyBranchName.of(arg) if arg else None
+            cli_opts.opt_branch = LocalBranchShortName.of(arg.replace('refs/heads/', '')) if arg else None
         elif opt == "by":
             cli_opts.opt_by = arg
         elif opt == "checked_out_since":
@@ -584,15 +582,6 @@ def set_utils_global_variables(parsed_args: argparse.Namespace) -> None:
     utils.verbose_mode = "verbose" in args
 
 
-def get_local_branch_short_name_from_arg_or_current_branch(
-        branch_from_arg: Optional[AnyBranchName], git_context: GitContext) -> LocalBranchShortName:
-    return get_local_branch_short_name_from_arg(branch_from_arg) if branch_from_arg else git_context.get_current_branch()
-
-
-def get_local_branch_short_name_from_arg(branch_from_arg: AnyBranchName) -> LocalBranchShortName:
-    return LocalBranchShortName.of(branch_from_arg.replace('refs/heads/', ''))
-
-
 def launch(orig_args: List[str]) -> None:
     initial_current_directory: Optional[str] = utils.get_current_directory_or_none()
 
@@ -610,7 +599,18 @@ def launch(orig_args: List[str]) -> None:
         set_utils_global_variables(parsed_cli)
         update_cli_options_using_config_keys(cli_opts, git)
         update_cli_options_using_parsed_args(cli_opts, parsed_cli)
-        cli_opts.validate()
+
+        if cli_opts.opt_no_interactive_rebase and cli_opts.opt_merge:
+            raise MacheteException(
+                "Option `--no-interactive-rebase` only makes sense when using "
+                "rebase and cannot be specified together with `-M/--merge`.")
+        if cli_opts.opt_fork_point and cli_opts.opt_merge:
+            raise MacheteException(
+                "Option `-f/--fork-point` only makes sense when using rebase and"
+                " cannot be specified together with `-M/--merge`.")
+        if cli_opts.opt_sync_github_prs and cli_opts.opt_sync_gitlab_mrs:
+            raise MacheteException(
+                "Option `-H/--sync-github-prs` cannot be specified together with `-L/--sync-gitlab-mrs`.")
 
         if not direct_args:
             print(get_help_description(display_help_topics=False))
@@ -650,11 +650,14 @@ def launch(orig_args: List[str]) -> None:
             version()
             return
 
-        should_perform_interactive_slide_out = MacheteClient.should_perform_interactive_slide_out(cmd)
         if cmd == "add":
+            if cli_opts.opt_as_root and cli_opts.opt_onto:
+                raise MacheteException("Option `-R/--as-root` cannot be specified together with `-o/--onto`.")
+            if cli_opts.opt_as_root and cli_opts.opt_as_first_child:
+                raise MacheteException("Option `-R/--as-root` cannot be specified together with `-f/--as-first-child`.")
             add_client = MacheteClient(git)
-            add_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
+            add_client.read_branch_layout_file()
+            branch = cli_opts.opt_branch or git.get_current_branch()
             add_client.add(
                 branch=branch,
                 opt_onto=cli_opts.opt_onto,
@@ -665,21 +668,17 @@ def launch(orig_args: List[str]) -> None:
                 switch_head_if_new_branch=True)
         elif cmd == "advance":
             advance_client = AdvanceMacheteClient(git)
-            advance_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
-            current_branch = git.get_current_branch()
-            advance_client.expect_in_managed_branches(current_branch)
-            advance_client.advance(branch=current_branch, opt_yes=cli_opts.opt_yes)
+            advance_client.read_branch_layout_file()
+            advance_client.advance(opt_yes=cli_opts.opt_yes)
         elif cmd == "anno":
             anno_client = AnnoMacheteClient(git)
-            anno_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out,
-                                                verify_branches=False)
+            anno_client.read_branch_layout_file(verify_branches=False)
             if cli_opts.opt_sync_github_prs:
                 anno_client.sync_annotations_to_prs(GitHubClient.spec(), include_urls=False)
             elif cli_opts.opt_sync_gitlab_mrs:
                 anno_client.sync_annotations_to_prs(GitLabClient.spec(), include_urls=False)
             else:
-                branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
+                branch = cli_opts.opt_branch or git.get_current_branch()
                 anno_client.expect_in_managed_branches(branch)
                 if parsed_cli.annotation_text:
                     anno_client.annotate(branch, parsed_cli.annotation_text)
@@ -687,25 +686,24 @@ def launch(orig_args: List[str]) -> None:
                     anno_client.print_annotation(branch)
         elif cmd == "clean":
             clean_client = MacheteClientWithCodeHosting(git)
-            clean_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            clean_client.read_branch_layout_file()
             if 'checkout_my_github_prs' in parsed_cli:
                 clean_client.checkout_pull_requests(GitHubClient.spec(), pr_numbers=[], mine=True)
             clean_client.delete_unmanaged(opt_squash_merge_detection=SquashMergeDetection.NONE, opt_yes=cli_opts.opt_yes)
             clean_client.delete_untracked(opt_yes=cli_opts.opt_yes)
         elif cmd == "delete-unmanaged":
             delete_unmanaged_client = MacheteClient(git)
-            delete_unmanaged_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            delete_unmanaged_client.read_branch_layout_file()
             opt_squash_merge_detection = SquashMergeDetection.from_string(
                 cli_opts.opt_squash_merge_detection_string, cli_opts.opt_squash_merge_detection_origin)
             delete_unmanaged_client.delete_unmanaged(opt_squash_merge_detection=opt_squash_merge_detection, opt_yes=cli_opts.opt_yes)
         elif cmd in {"diff", alias_by_command["diff"]}:
             diff_client = DiffMacheteClient(git)
-            diff_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            diff_branch = get_local_branch_short_name_from_arg(cli_opts.opt_branch) if (cli_opts.opt_branch is not None) else None
-            diff_client.display_diff(branch=diff_branch, opt_stat=cli_opts.opt_stat, extra_git_diff_args=pass_through_args)
+            diff_client.read_branch_layout_file()
+            diff_client.display_diff(branch=cli_opts.opt_branch, opt_stat=cli_opts.opt_stat, extra_git_diff_args=pass_through_args)
         elif cmd == "discover":
             discover_client = DiscoverMacheteClient(git)
-            discover_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            discover_client.read_branch_layout_file()
             discover_client.discover(
                 opt_checked_out_since=cli_opts.opt_checked_out_since,
                 opt_list_commits=cli_opts.opt_list_commits,
@@ -721,8 +719,8 @@ def launch(orig_args: List[str]) -> None:
             print(os.path.abspath(file_client.branch_layout_file_path))
         elif cmd == "fork-point":
             fork_point_client = ForkPointMacheteClient(git)
-            fork_point_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
+            fork_point_client.read_branch_layout_file()
+            branch = cli_opts.opt_branch or git.get_current_branch()
             fork_point_client.expect_in_local_branches(branch)
             if cli_opts.opt_inferred:
                 print(fork_point_client.fork_point(branch=branch, use_overrides=False))
@@ -744,7 +742,7 @@ def launch(orig_args: List[str]) -> None:
                 print(fork_point_client.fork_point(branch=branch, use_overrides=True))
         elif cmd in {"go", alias_by_command["go"]}:
             go_client = GoShowMacheteClient(git)
-            go_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            go_client.read_branch_layout_file()
             git.expect_no_operation_in_progress()
             current_branch = git.get_current_branch()
             dest = go_client.parse_direction(parsed_cli.direction, current_branch, allow_current=False, down_pick_mode=True)[0]
@@ -757,7 +755,7 @@ def launch(orig_args: List[str]) -> None:
             pr_or_mr = spec.pr_short_name.lower()
 
             github_or_gitlab_client = MacheteClientWithCodeHosting(git)
-            github_or_gitlab_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            github_or_gitlab_client.read_branch_layout_file()
 
             if "request_id" in parsed_cli and subcommand != f"checkout-{pr_or_mr}s":
                 raise MacheteException(f"{spec.pr_short_name} number is only valid with `checkout-{pr_or_mr}s` subcommand.")
@@ -814,7 +812,7 @@ def launch(orig_args: List[str]) -> None:
             elif subcommand == f"restack-{pr_or_mr}":
                 github_or_gitlab_client.restack_pull_request(spec, opt_update_related_descriptions=cli_opts.opt_update_related_descriptions)
             elif subcommand == f"retarget-{pr_or_mr}":
-                branch = parsed_cli.branch if 'branch' in parsed_cli else git.get_current_branch()
+                branch = cli_opts.opt_branch or git.get_current_branch()
                 github_or_gitlab_client.expect_in_managed_branches(branch)
                 github_or_gitlab_client.retarget_pull_request(
                     spec,
@@ -837,31 +835,22 @@ def launch(orig_args: List[str]) -> None:
                 raise UnexpectedMacheteException(f"Unknown subcommand: `{subcommand}`")
         elif cmd == "is-managed":
             is_managed_client = MacheteClient(git)
-            is_managed_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
+            is_managed_client.read_branch_layout_file()
+            branch = cli_opts.opt_branch or git.get_current_branch()
             if branch is None or branch not in is_managed_client.managed_branches:
                 sys.exit(ExitCode.MACHETE_EXCEPTION)
         elif cmd == "list":
             list_client = MacheteClient(git)
             category = parsed_cli.category
-            if category == 'slidable-after' and 'branch' not in parsed_cli:
+            if category == 'slidable-after' and not cli_opts.opt_branch:
                 raise MacheteException(f"`git machete list {category}` requires an extra <branch> argument")
-            elif category != 'slidable-after' and 'branch' in parsed_cli:
+            elif category != 'slidable-after' and cli_opts.opt_branch:
                 raise MacheteException(f"`git machete list {category}` does not expect extra arguments")
 
-            list_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            list_client.read_branch_layout_file()
             res = []
             if category == "addable":
-                def strip_remote_name(remote_branch: RemoteBranchShortName) -> LocalBranchShortName:
-                    return LocalBranchShortName.of(re.sub("^[^/]+/", "", remote_branch))
-
-                remote_counterparts_of_local_branches = utils.map_truthy_only(
-                    git.get_combined_counterpart_for_fetching_of_branch,
-                    git.get_local_branches())
-                qualifying_remote_branches: List[RemoteBranchShortName] = \
-                    excluding(git.get_remote_branches(), remote_counterparts_of_local_branches)
-                res = excluding(git.get_local_branches(), list_client.managed_branches) + [
-                    strip_remote_name(branch) for branch in qualifying_remote_branches]
+                res = list_client.addable_branches
             elif category == "childless":
                 res = list_client.childless_managed_branches
             elif category == "managed":
@@ -872,12 +861,9 @@ def launch(orig_args: List[str]) -> None:
                 list_client.expect_in_managed_branches(parsed_cli.branch)
                 res = list_client.get_slidable_after(parsed_cli.branch)
             elif category == "unmanaged":
-                res = excluding(git.get_local_branches(), list_client.managed_branches)
+                res = list_client.unmanaged_branches
             elif category == "with-overridden-fork-point":
-                res = list(
-                    filter(
-                        lambda _branch: list_client.has_any_fork_point_override_config(_branch),
-                        git.get_local_branches()))
+                res = list_client.branches_with_overridden_fork_point
             else:  # an unknown category is handled by argparse
                 raise UnexpectedMacheteException(f"Invalid category: `{category}`")
 
@@ -885,13 +871,12 @@ def launch(orig_args: List[str]) -> None:
                 print("\n".join(res))
         elif cmd in {"log", alias_by_command["log"]}:
             log_client = LogMacheteClient(git)
-            log_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
+            log_client.read_branch_layout_file()
+            branch = cli_opts.opt_branch or git.get_current_branch()
             log_client.display_log(branch, extra_git_log_args=pass_through_args)
         elif cmd == "reapply":
             reapply_client = MacheteClient(git)
-            reapply_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
+            reapply_client.read_branch_layout_file()
             current_branch = git.get_current_branch()
             if cli_opts.opt_fork_point is not None:
                 reapply_client.check_that_fork_point_is_ancestor_or_equal_to_tip_of_branch(
@@ -902,16 +887,19 @@ def launch(orig_args: List[str]) -> None:
         elif cmd == "show":
             show_client = GoShowMacheteClient(git)
             direction = parsed_cli.direction
-            if direction == "current" and "branch" in parsed_cli:
+            if direction == "current" and cli_opts.opt_branch:
                 raise MacheteException('`show current` with a `<branch>` argument does not make sense')
-            branch = get_local_branch_short_name_from_arg_or_current_branch(cli_opts.opt_branch, git)
-            show_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out,
-                                                verify_branches=False)
+            branch = cli_opts.opt_branch or git.get_current_branch()
+            show_client.read_branch_layout_file(verify_branches=False)
             print('\n'.join(show_client.parse_direction(direction, branch, allow_current=True, down_pick_mode=False)))
         elif cmd == "slide-out":
+            if cli_opts.opt_down_fork_point and cli_opts.opt_merge:
+                raise MacheteException(
+                    "Option `-d/--down-fork-point` only makes sense when using "
+                    "rebase and cannot be specified together with `-M/--merge`.")
+
             slide_out_client = SlideOutMacheteClient(git)
-            slide_out_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
+            slide_out_client.read_branch_layout_file()
             branches_to_slide_out: Optional[List[str]] = parsed_cli_as_dict.get('branches')
             if cli_opts.opt_removed_from_remote:
                 if branches_to_slide_out or cli_opts.opt_down_fork_point or cli_opts.opt_merge or cli_opts.opt_no_interactive_rebase:
@@ -928,8 +916,7 @@ def launch(orig_args: List[str]) -> None:
                     opt_no_edit_merge=cli_opts.opt_no_edit_merge)
         elif cmd == "squash":
             squash_client = SquashMacheteClient(git)
-            squash_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
+            squash_client.read_branch_layout_file()
             current_branch = git.get_current_branch()
             if cli_opts.opt_fork_point is not None:
                 squash_client.check_that_fork_point_is_ancestor_or_equal_to_tip_of_branch(
@@ -949,7 +936,7 @@ def launch(orig_args: List[str]) -> None:
             opt_squash_merge_detection = SquashMergeDetection.from_string(
                 cli_opts.opt_squash_merge_detection_string, cli_opts.opt_squash_merge_detection_origin)
 
-            status_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
+            status_client.read_branch_layout_file(interactively_slide_out_invalid_branches=utils.is_stdout_a_tty())
             status_client.expect_at_least_one_managed_branch()
             status_client.status(
                 warn_when_branch_in_sync_but_fork_point_off=True,
@@ -969,8 +956,7 @@ def launch(orig_args: List[str]) -> None:
             opt_start_from = TraverseStartFrom.from_string(cli_opts.opt_start_from, "`--start-from` flag")
 
             traverse_client = TraverseMacheteClient(git)
-            traverse_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
+            traverse_client.read_branch_layout_file(interactively_slide_out_invalid_branches=utils.is_stdout_a_tty())
             traverse_client.traverse(
                 opt_fetch=cli_opts.opt_fetch,
                 opt_list_commits=cli_opts.opt_list_commits,
@@ -987,8 +973,7 @@ def launch(orig_args: List[str]) -> None:
                 opt_yes=cli_opts.opt_yes)
         elif cmd == "update":
             update_client = UpdateMacheteClient(git)
-            update_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out)
-            git.expect_no_operation_in_progress()
+            update_client.read_branch_layout_file()
             if cli_opts.opt_fork_point is not None:
                 update_client.check_that_fork_point_is_ancestor_or_equal_to_tip_of_branch(
                     fork_point_hash=cli_opts.opt_fork_point, branch=git.get_current_branch())
diff --git a/git_machete/client/advance.py b/git_machete/client/advance.py
index 28f2013..4ec0064 100644
--- a/git_machete/client/advance.py
+++ b/git_machete/client/advance.py
@@ -8,7 +8,12 @@ class AdvanceMacheteClient(MacheteClient):
     def __init__(self, git: GitContext) -> None:
         super().__init__(git)
 
-    def advance(self, *, branch: LocalBranchShortName, opt_yes: bool) -> None:
+    def advance(self, opt_yes: bool) -> None:
+        self._git.expect_no_operation_in_progress()
+
+        branch = self._git.get_current_branch()
+        self.expect_in_managed_branches(branch)
+
         down_branches = self.down_branches_for(branch)
         if not down_branches:
             raise MacheteException(
diff --git a/git_machete/client/base.py b/git_machete/client/base.py
index de09c1f..3d7ebbc 100644
--- a/git_machete/client/base.py
+++ b/git_machete/client/base.py
@@ -1,6 +1,7 @@
 import io
 import itertools
 import os
+import re
 import shlex
 import shutil
 import sys
@@ -155,6 +156,32 @@ class MacheteClient:
     def managed_branches(self) -> List[LocalBranchShortName]:
         return self._state.managed_branches
 
+    @property
+    def addable_branches(self) -> List[LocalBranchShortName]:
+        def strip_remote_name(remote_branch: RemoteBranchShortName) -> LocalBranchShortName:
+            return LocalBranchShortName.of(re.sub("^[^/]+/", "", remote_branch))
+
+        remote_counterparts_of_local_branches = utils.map_truthy_only(
+            self._git.get_combined_counterpart_for_fetching_of_branch,
+            self._git.get_local_branches())
+        qualifying_remote_branches: List[RemoteBranchShortName] = \
+            excluding(self._git.get_remote_branches(), remote_counterparts_of_local_branches)
+        return excluding(self._git.get_local_branches(), self.managed_branches) + [
+            strip_remote_name(branch) for branch in qualifying_remote_branches]
+
+    @property
+    def unmanaged_branches(self) -> List[LocalBranchShortName]:
+        return excluding(self._git.get_local_branches(), self.managed_branches)
+
+    @property
+    def childless_managed_branches(self) -> List[LocalBranchShortName]:
+        parent_branches = [parent_branch for parent_branch, child_branches in self._state.down_branches_for.items() if child_branches]
+        return excluding(self.managed_branches, parent_branches)
+
+    @property
+    def branches_with_overridden_fork_point(self) -> List[LocalBranchShortName]:
+        return [branch for branch in self._git.get_local_branches() if self.has_any_fork_point_override_config(branch)]
+
     @property
     def annotations(self) -> Dict[LocalBranchShortName, Annotation]:
         return self._state.annotations
@@ -165,11 +192,6 @@ class MacheteClient:
     def down_branches_for(self, branch: LocalBranchShortName) -> Optional[List[LocalBranchShortName]]:
         return self._state.down_branches_for.get(branch)
 
-    @property
-    def childless_managed_branches(self) -> List[LocalBranchShortName]:
-        parent_branches = [parent_branch for parent_branch, child_branches in self._state.down_branches_for.items() if child_branches]
-        return excluding(self.managed_branches, parent_branches)
-
     def expect_in_managed_branches(self, branch: LocalBranchShortName) -> None:
         if branch not in self.managed_branches:
             raise MacheteException(
@@ -193,7 +215,7 @@ class MacheteClient:
                 * `git machete github checkout-prs --mine`
                 * `git machete gitlab checkout-mrs --mine`"""[1:]))
 
-    def read_branch_layout_file(self, perform_interactive_slide_out: bool, verify_branches: bool = True) -> None:
+    def read_branch_layout_file(self, *, interactively_slide_out_invalid_branches: bool = False, verify_branches: bool = True) -> None:
         with open(self._branch_layout_file_path) as file:
             lines: List[str] = [line.rstrip() for line in file.readlines()]
 
@@ -257,7 +279,7 @@ class MacheteClient:
         if not invalid_branches:
             return
 
-        if perform_interactive_slide_out:
+        if interactively_slide_out_invalid_branches:
             if len(invalid_branches) == 1:
                 ans: str = self.ask_if(
                     f"Skipping {bold(invalid_branches[0])} " +
@@ -305,7 +327,7 @@ class MacheteClient:
         elif ans in ('e', 'edit'):
             self.edit()
             self.__init_state()
-            self.read_branch_layout_file(verify_branches)
+            self.read_branch_layout_file(verify_branches=verify_branches)
 
     def render_branch_layout_file(self, indent: str) -> List[str]:
         def render_dfs(branch: LocalBranchShortName, depth: int) -> List[str]:
@@ -667,6 +689,8 @@ class MacheteClient:
             return utils.run_cmd(*args, cwd=cwd)
 
     def rebase(self, onto: AnyRevision, from_exclusive: AnyRevision, branch: LocalBranchShortName, opt_no_interactive_rebase: bool) -> None:
+        self._git.expect_no_operation_in_progress()
+
         anno = self.annotations.get(branch)
         if anno and not anno.qualifiers.rebase:
             raise MacheteException(f"Branch {bold(branch)} is annotated with `rebase=no` qualifier, aborting.\n"
@@ -1550,12 +1574,3 @@ class MacheteClient:
                 self._state.roots.remove(branch)
 
         self.save_branch_layout_file()
-
-    @staticmethod
-    def is_stdout_a_tty() -> bool:
-        return sys.stdout.isatty()
-
-    @staticmethod
-    def should_perform_interactive_slide_out(cmd: str) -> bool:
-        interactive_slide_out_safe_commands = {'traverse', 'status'}
-        return MacheteClient.is_stdout_a_tty() and cmd in interactive_slide_out_safe_commands
diff --git a/git_machete/client/slide_out.py b/git_machete/client/slide_out.py
index 73b4dfe..b8fc177 100644
--- a/git_machete/client/slide_out.py
+++ b/git_machete/client/slide_out.py
@@ -16,6 +16,8 @@ class SlideOutMacheteClient(MacheteClient):
                   opt_no_interactive_rebase: bool,
                   opt_no_edit_merge: bool
                   ) -> None:
+        self._git.expect_no_operation_in_progress()
+
         # Verify that all branches exist, are managed, have an upstream and are NOT annotated with slide-out=no qualifier.
         for branch in branches_to_slide_out:
             self.expect_in_managed_branches(branch)
@@ -104,6 +106,8 @@ class SlideOutMacheteClient(MacheteClient):
                                   opt_squash_merge_detection=SquashMergeDetection.NONE, opt_yes=False)
 
     def slide_out_removed_from_remote(self, opt_delete: bool) -> None:
+        self._git.expect_no_operation_in_progress()
+
         slid_out_branches: List[LocalBranchShortName] = []
         for branch in self.managed_branches.copy():
             if self._git.is_removed_from_remote(branch) and not self.down_branches_for(branch):
diff --git a/git_machete/client/squash.py b/git_machete/client/squash.py
index 787d180..d156bf4 100644
--- a/git_machete/client/squash.py
+++ b/git_machete/client/squash.py
@@ -11,6 +11,8 @@ from git_machete.utils import bold, fmt
 
 class SquashMacheteClient(MacheteClient):
     def squash(self, *, current_branch: LocalBranchShortName, opt_fork_point: AnyRevision) -> None:
+        self._git.expect_no_operation_in_progress()
+
         commits: List[GitLogEntry] = self._git.get_commits_between(opt_fork_point, current_branch)
         if not commits:
             raise MacheteException(
diff --git a/git_machete/client/traverse.py b/git_machete/client/traverse.py
index c21d639..fbcf99e 100644
--- a/git_machete/client/traverse.py
+++ b/git_machete/client/traverse.py
@@ -33,6 +33,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
             opt_sync_gitlab_mrs: bool,
             opt_yes: bool
     ) -> None:
+        self._git.expect_no_operation_in_progress()
         self.expect_at_least_one_managed_branch()
 
         self._set_empty_line_status()
diff --git a/git_machete/client/update.py b/git_machete/client/update.py
index 2326d7e..fc44b35 100644
--- a/git_machete/client/update.py
+++ b/git_machete/client/update.py
@@ -9,6 +9,7 @@ class UpdateMacheteClient(MacheteClient):
     def update(
             self, *, opt_merge: bool, opt_no_edit_merge: bool,
             opt_no_interactive_rebase: bool, opt_fork_point: Optional[AnyRevision]) -> None:
+        self._git.expect_no_operation_in_progress()
         current_branch = self._git.get_current_branch()
         use_merge = opt_merge or (current_branch in self.annotations and self.annotations[current_branch].qualifiers.update_with_merge)
         if use_merge:
diff --git a/git_machete/options.py b/git_machete/options.py
index 2c19879..dd8775c 100644
--- a/git_machete/options.py
+++ b/git_machete/options.py
@@ -1,7 +1,6 @@
 from typing import List, Optional
 
-from .exceptions import MacheteException
-from .git_operations import AnyBranchName, AnyRevision, LocalBranchShortName
+from .git_operations import AnyRevision, LocalBranchShortName
 
 
 class CommandLineOptions:
@@ -10,7 +9,7 @@ class CommandLineOptions:
         self.opt_all: bool = False
         self.opt_as_first_child: bool = False
         self.opt_as_root: bool = False
-        self.opt_branch: Optional[AnyBranchName] = None
+        self.opt_branch: Optional[LocalBranchShortName] = None
         self.opt_by: Optional[str] = None
         self.opt_checked_out_since: Optional[str] = None
         self.opt_delete: bool = False
@@ -49,29 +48,6 @@ class CommandLineOptions:
         self.opt_with_urls: bool = False
         self.opt_yes: bool = False
 
-    def validate(self) -> None:
-        if self.opt_as_root and self.opt_onto:
-            raise MacheteException(
-                "Option `-R/--as-root` cannot be specified together with `-o/--onto`.")
-        if self.opt_as_root and self.opt_as_first_child:
-            raise MacheteException(
-                "Option `-R/--as-root` cannot be specified together with `-f/--as-first-child`.")
-        if self.opt_no_interactive_rebase and self.opt_merge:
-            raise MacheteException(
-                "Option `--no-interactive-rebase` only makes sense when using "
-                "rebase and cannot be specified together with `-M/--merge`.")
-        if self.opt_down_fork_point and self.opt_merge:
-            raise MacheteException(
-                "Option `-d/--down-fork-point` only makes sense when using "
-                "rebase and cannot be specified together with `-M/--merge`.")
-        if self.opt_fork_point and self.opt_merge:
-            raise MacheteException(
-                "Option `-f/--fork-point` only makes sense when using rebase and"
-                " cannot be specified together with `-M/--merge`.")
-        if self.opt_sync_github_prs and self.opt_sync_gitlab_mrs:
-            raise MacheteException(
-                "Option `-H/--sync-github-prs` cannot be specified together with `-L/--sync-gitlab-mrs`.")
-
     def __repr__(self) -> str:  # pragma: no cover; debug only
         attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
         return f"{self.__class__.__module__}.{self.__class__.__qualname__}({attrs})"
diff --git a/git_machete/utils.py b/git_machete/utils.py
index de14ee2..6938145 100644
--- a/git_machete/utils.py
+++ b/git_machete/utils.py
@@ -29,6 +29,10 @@ CODE_HOSTING_TOKEN_PREFIXES = ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'glpat-']
 CODE_HOSTING_TOKEN_PREFIX_REGEX = '(' + '|'.join(CODE_HOSTING_TOKEN_PREFIXES) + ')'
 
 
+def is_stdout_a_tty() -> bool:
+    return sys.stdout.isatty()
+
+
 def excluding(iterable: Iterable[T], s: Iterable[T]) -> List[T]:
     return list(filter(lambda x: x not in s, iterable))
 
diff --git a/tox.ini b/tox.ini
index 2d3c555..663a28a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -117,6 +117,7 @@ exclude_lines =
   except InterruptedError:
   except KeyboardInterrupt:
   if __name__ == .__main__.:
+  if sys.platform == .win32.:
   pragma: no cover
   raise UnexpectedMacheteException
 


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index c09d0a5..5dd3963 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -57,7 +57,7 @@ class TestClient(BaseTest):
             """
         rewrite_branch_layout_file(body)
         machete_client = MacheteClient(GitContext())
-        machete_client.read_branch_layout_file(perform_interactive_slide_out=False)
+        machete_client.read_branch_layout_file(interactively_slide_out_invalid_branches=False)
         annotations: Dict[LocalBranchShortName, Annotation] = machete_client.annotations
 
         feature_2_branch = LocalBranchShortName.of('feature2')
diff --git a/tests/test_status.py b/tests/test_status.py
index 2f03cd6..05da99e 100644
--- a/tests/test_status.py
+++ b/tests/test_status.py
@@ -65,7 +65,7 @@ class TestStatus(BaseTest):
         assert_failure(['status'], expected_error_message)
 
     def test_single_invalid_branch_interactive_slide_out(self, mocker: MockerFixture) -> None:
-        self.patch_symbol(mocker, "git_machete.client.base.MacheteClient.is_stdout_a_tty", lambda: True)
+        self.patch_symbol(mocker, "git_machete.utils.is_stdout_a_tty", lambda: True)
 
         create_repo()
         new_branch('master')
@@ -92,7 +92,7 @@ class TestStatus(BaseTest):
             assert_success(["status"], expected_output)
 
     def test_multiple_invalid_branches_interactive_slide_out(self, mocker: MockerFixture) -> None:
-        self.patch_symbol(mocker, "git_machete.client.base.MacheteClient.is_stdout_a_tty", lambda: True)
+        self.patch_symbol(mocker, "git_machete.utils.is_stdout_a_tty", lambda: True)
 
         create_repo()
         new_branch('master')