Repository: VirtusLab/git-machete
Pull request #1431 (2025-05-05 17:39:04)

Stop passing `spec: CodeHostingSpec` around in methods

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1431?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.56%. Comparing base [(`baa861d`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/baa861d3450ea32c880bc753aa30a17e515e0727?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) to head [(`076d4db`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/076d4db5e6936f6ac5dbc9cae781274455767dbb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).
> Report is 1 commits behind head on develop.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop    #1431      +/-   ##
===========================================
+ Coverage    98.51%   98.56%   +0.04%     
===========================================
  Files           26       26              
  Lines         4171     4174       +3     
  Branches       789      788       -1     
===========================================
+ Hits          4109     4114       +5     
  Misses          38       38              
+ Partials        24       22       -2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1431?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index fbb15a2..259c5be 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 25, 2025" "" "git-machete"
+.TH "GIT-MACHETE" "1" "May 05, 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 cef4cd2..c2cc4c6 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -25,8 +25,8 @@ from git_machete.client.squash import SquashMacheteClient
 from git_machete.client.traverse import TraverseMacheteClient
 from git_machete.client.update import UpdateMacheteClient
 from git_machete.client.with_code_hosting import MacheteClientWithCodeHosting
-from git_machete.github import GitHubClient
-from git_machete.gitlab import GitLabClient
+from git_machete.github import GITHUB_CLIENT_SPEC
+from git_machete.gitlab import GITLAB_CLIENT_SPEC
 
 from .exceptions import (ExitCode, InteractionStopped, MacheteException,
                          UnderlyingGitException, UnexpectedMacheteException)
@@ -671,12 +671,11 @@ def launch(orig_args: List[str]) -> None:
             advance_client.read_branch_layout_file()
             advance_client.advance(opt_yes=cli_opts.opt_yes)
         elif cmd == "anno":
-            anno_client = AnnoMacheteClient(git)
+            spec = GITHUB_CLIENT_SPEC if cli_opts.opt_sync_github_prs else GITLAB_CLIENT_SPEC
+            anno_client = AnnoMacheteClient(git, spec)
             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)
+            if cli_opts.opt_sync_github_prs or cli_opts.opt_sync_gitlab_mrs:
+                anno_client.sync_annotations_to_prs(include_urls=False)
             else:
                 branch = cli_opts.opt_branch or git.get_current_branch()
                 anno_client.expect_in_managed_branches(branch)
@@ -685,10 +684,10 @@ def launch(orig_args: List[str]) -> None:
                 else:
                     anno_client.print_annotation(branch)
         elif cmd == "clean":
-            clean_client = MacheteClientWithCodeHosting(git)
+            clean_client = MacheteClientWithCodeHosting(git, GITHUB_CLIENT_SPEC)
             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.checkout_pull_requests(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":
@@ -751,10 +750,10 @@ def launch(orig_args: List[str]) -> None:
                 git.checkout(dest)
         elif cmd in ("github", "gitlab"):
             subcommand = parsed_cli.subcommand
-            spec = GitHubClient.spec() if cmd == "github" else GitLabClient.spec()
+            spec = GITHUB_CLIENT_SPEC if cmd == "github" else GITLAB_CLIENT_SPEC
             pr_or_mr = spec.pr_short_name.lower()
 
-            github_or_gitlab_client = MacheteClientWithCodeHosting(git)
+            github_or_gitlab_client = MacheteClientWithCodeHosting(git, spec)
             github_or_gitlab_client.read_branch_layout_file()
 
             if "request_id" in parsed_cli and subcommand != f"checkout-{pr_or_mr}s":
@@ -786,14 +785,13 @@ def launch(orig_args: List[str]) -> None:
                 raise MacheteException(f"`--yes` option is only valid with `create-{pr_or_mr}` subcommand.")
 
             if subcommand == f"anno-{pr_or_mr}s":
-                github_or_gitlab_client.sync_annotations_to_prs(spec, include_urls=cli_opts.opt_with_urls)
+                github_or_gitlab_client.sync_annotations_to_prs(include_urls=cli_opts.opt_with_urls)
             elif subcommand == f"checkout-{pr_or_mr}s":
                 if len(set(parsed_cli_as_dict.keys()).intersection({'all', 'by', 'mine', 'request_id'})) != 1:
                     raise MacheteException(
                         f"`checkout-{pr_or_mr}s` subcommand must take exactly one of the following options: "
                         f'`--all`, `--by=...`, `--mine`, `{pr_or_mr}-number(s)`')
                 github_or_gitlab_client.checkout_pull_requests(
-                    spec,
                     pr_numbers=parsed_cli.request_id if 'request_id' in parsed_cli else [],
                     all=cli_opts.opt_all,
                     mine=cli_opts.opt_mine,
@@ -802,7 +800,6 @@ def launch(orig_args: List[str]) -> None:
             elif subcommand == f"create-{pr_or_mr}":
                 current_branch = git.get_current_branch()
                 github_or_gitlab_client.create_pull_request(
-                    spec,
                     head=current_branch,
                     opt_draft=cli_opts.opt_draft,
                     opt_onto=cli_opts.opt_onto,
@@ -810,18 +807,17 @@ def launch(orig_args: List[str]) -> None:
                     opt_update_related_descriptions=cli_opts.opt_update_related_descriptions,
                     opt_yes=cli_opts.opt_yes)
             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)
+                github_or_gitlab_client.restack_pull_request(opt_update_related_descriptions=cli_opts.opt_update_related_descriptions)
             elif subcommand == f"retarget-{pr_or_mr}":
                 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,
                     head=branch,
                     opt_ignore_if_missing=cli_opts.opt_ignore_if_missing,
                     opt_update_related_descriptions=cli_opts.opt_update_related_descriptions
                 )
             elif subcommand == "sync":  # GitHub only
-                github_or_gitlab_client.checkout_pull_requests(spec, pr_numbers=[], mine=True)
+                github_or_gitlab_client.checkout_pull_requests(pr_numbers=[], mine=True)
                 github_or_gitlab_client.delete_unmanaged(opt_squash_merge_detection=SquashMergeDetection.NONE, opt_yes=False)
                 github_or_gitlab_client.delete_untracked(opt_yes=cli_opts.opt_yes)
             elif subcommand == f"update-{pr_or_mr}-descriptions":
@@ -830,7 +826,7 @@ def launch(orig_args: List[str]) -> None:
                         f"`update-{pr_or_mr}-descriptions` subcommand must take exactly one of the following options: "
                         '`--all`, `--by=...`, `--mine`, `--related`')
                 github_or_gitlab_client.update_pull_request_descriptions(
-                    spec, all=cli_opts.opt_all, by=cli_opts.opt_by, mine=cli_opts.opt_mine, related=cli_opts.opt_related)
+                    all=cli_opts.opt_all, by=cli_opts.opt_by, mine=cli_opts.opt_mine, related=cli_opts.opt_related)
             else:  # an unknown subcommand is handled by argparse
                 raise UnexpectedMacheteException(f"Unknown subcommand: `{subcommand}`")
         elif cmd == "is-managed":
@@ -955,7 +951,8 @@ def launch(orig_args: List[str]) -> None:
                 cli_opts.opt_squash_merge_detection_string, cli_opts.opt_squash_merge_detection_origin)
             opt_start_from = TraverseStartFrom.from_string(cli_opts.opt_start_from, "`--start-from` flag")
 
-            traverse_client = TraverseMacheteClient(git)
+            spec = GITHUB_CLIENT_SPEC if cli_opts.opt_sync_github_prs else GITLAB_CLIENT_SPEC
+            traverse_client = TraverseMacheteClient(git, spec)
             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,
diff --git a/git_machete/client/base.py b/git_machete/client/base.py
index 3d7ebbc..610c322 100644
--- a/git_machete/client/base.py
+++ b/git_machete/client/base.py
@@ -13,7 +13,6 @@ from typing import (Callable, Dict, Iterator, List, Optional, Tuple, Type,
 
 from git_machete import git_config_keys, utils
 from git_machete.annotation import Annotation
-from git_machete.code_hosting import CodeHostingClient, CodeHostingSpec
 from git_machete.constants import (INITIAL_COMMIT_COUNT_FOR_LOG,
                                    TOTAL_COMMIT_COUNT_FOR_LOG)
 from git_machete.exceptions import (InteractionStopped, MacheteException,
@@ -134,20 +133,6 @@ class MacheteClient:
         self.__empty_line_status: Optional[bool] = None
         self.__branch_pairs_by_hash_in_reflog: Optional[Dict[FullCommitHash, List[BranchPair]]] = None
 
-    @property
-    def code_hosting_spec(self) -> CodeHostingSpec:
-        return self.code_hosting_client._spec
-
-    @property
-    def code_hosting_client(self) -> CodeHostingClient:
-        if self.__code_hosting_client is None:
-            raise UnexpectedMacheteException("Code hosting client has not been initialized, this is an unexpected state.")
-        return self.__code_hosting_client
-
-    @code_hosting_client.setter
-    def code_hosting_client(self, value: CodeHostingClient) -> None:
-        self.__code_hosting_client = value
-
     @property
     def branch_layout_file_path(self) -> str:
         return self._branch_layout_file_path
diff --git a/git_machete/client/traverse.py b/git_machete/client/traverse.py
index fbcf99e..04b2381 100644
--- a/git_machete/client/traverse.py
+++ b/git_machete/client/traverse.py
@@ -9,8 +9,6 @@ from git_machete.code_hosting import PullRequest
 from git_machete.exceptions import (InteractionStopped, MacheteException,
                                     UnexpectedMacheteException)
 from git_machete.git_operations import LocalBranchShortName, SyncToRemoteStatus
-from git_machete.github import GitHubClient
-from git_machete.gitlab import GitLabClient
 from git_machete.utils import (bold, flat_map, fmt, get_pretty_choices,
                                get_right_arrow)
 
@@ -49,8 +47,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
 
         current_user: Optional[str] = None
         if opt_sync_github_prs or opt_sync_gitlab_mrs:
-            spec = GitHubClient.spec() if opt_sync_github_prs else GitLabClient.spec()
-            self._init_code_hosting_client(spec)
+            self._init_code_hosting_client()
             current_user = self.code_hosting_client.get_current_user_login()
 
         initial_branch = nearest_remaining_branch = self._git.get_current_branch()
@@ -100,7 +97,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
             if opt_sync_github_prs or opt_sync_gitlab_mrs:
                 prs = list(filter(lambda pr: pr.head == branch, self._get_all_open_prs()))
                 if len(prs) > 1:
-                    spec = self.code_hosting_client._spec
+                    spec = self.code_hosting_spec
                     raise MacheteException(
                         f"Multiple {spec.pr_short_name}s have <b>{branch}</b> as its {spec.head_branch_name} branch: " +
                         ", ".join(_pr.short_display_text() for _pr in prs))
@@ -260,7 +257,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
                 any_action_suggested = True
                 assert pr is not None
                 assert upstream is not None
-                spec = self.code_hosting_client._spec
+                spec = self.code_hosting_spec
                 self._print_new_line(False)
                 ans_intro = f"Branch {bold(str(branch))} has a different {spec.pr_short_name} {spec.base_branch_name} ({bold(pr.base)}) " \
                     f"in {spec.display_name} than in machete file ({bold(str(upstream))}).\n"
@@ -274,7 +271,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
                     pr.base = upstream
 
                     anno = self._state.annotations.get(branch)
-                    self._state.annotations[branch] = Annotation(self._pull_request_annotation(spec, pr, current_user),
+                    self._state.annotations[branch] = Annotation(self._pull_request_annotation(pr, current_user),
                                                                  anno.qualifiers if anno else Qualifiers())
                     self.save_branch_layout_file()
 
diff --git a/git_machete/client/with_code_hosting.py b/git_machete/client/with_code_hosting.py
index 4a49542..c8d1990 100644
--- a/git_machete/client/with_code_hosting.py
+++ b/git_machete/client/with_code_hosting.py
@@ -30,11 +30,26 @@ class PRDescriptionIntroStyle(ParsableEnum):
 
 
 class MacheteClientWithCodeHosting(MacheteClient):
-    def __init__(self, git: GitContext):
+    def __init__(self, git: GitContext, spec: CodeHostingSpec):
         super().__init__(git)
+        self.__code_hosting_spec: CodeHostingSpec = spec
         self.__code_hosting_client: Optional[CodeHostingClient] = None
         self.__all_open_prs: Optional[List[PullRequest]] = None
 
+    @property
+    def code_hosting_spec(self) -> CodeHostingSpec:
+        return self.__code_hosting_spec
+
+    @property
+    def code_hosting_client(self) -> CodeHostingClient:
+        if self.__code_hosting_client is None:
+            raise UnexpectedMacheteException("Code hosting client has not been initialized, this is an unexpected state.")
+        return self.__code_hosting_client
+
+    @code_hosting_client.setter
+    def code_hosting_client(self, value: CodeHostingClient) -> None:
+        self.__code_hosting_client = value
+
     def _get_all_open_prs(self) -> List[PullRequest]:
         if self.__all_open_prs is None:
             spec = self.code_hosting_spec
@@ -43,22 +58,22 @@ class MacheteClientWithCodeHosting(MacheteClient):
             print(fmt('<green><b>OK</b></green>'))
         return self.__all_open_prs
 
-    def _pull_request_annotation(self, spec: CodeHostingSpec,
-                                 pr: PullRequest, current_user: Optional[str], include_url: bool = False) -> str:
+    def _pull_request_annotation(self, pr: PullRequest, current_user: Optional[str], include_url: bool = False) -> str:
         anno = pr.display_text(fmt=False)
         if current_user != pr.user:
             anno += f" ({pr.user})"
-        config_key = spec.git_config_keys.annotate_with_urls
+        config_key = self.code_hosting_spec.git_config_keys.annotate_with_urls
         if include_url or self._git.get_boolean_config_attr(key=config_key, default_value=False):
             anno += f" {pr.html_url}"
         return anno
 
-    def __sync_annotations_to_branch_layout_file(self, spec: CodeHostingSpec, prs: List[PullRequest], current_user: Optional[str],
+    def __sync_annotations_to_branch_layout_file(self, prs: List[PullRequest], current_user: Optional[str],
                                                  include_urls: bool, verbose: bool) -> None:
+        spec = self.code_hosting_spec
         for pr in prs:
             if LocalBranchShortName.of(pr.head) in self.managed_branches:
                 debug(f'{pr} corresponds to a managed branch')
-                anno: str = self._pull_request_annotation(spec, pr, current_user, include_urls)
+                anno: str = self._pull_request_annotation(pr, current_user, include_urls)
                 upstream: Optional[LocalBranchShortName] = self.up_branch_for(LocalBranchShortName.of(pr.head))
                 if upstream is not None:
                     counterpart = self._git.get_combined_counterpart_for_fetching_of_branch(upstream)
@@ -105,9 +120,8 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 return remote
         return None
 
-    def __sync_before_creating_pull_request(self, spec: CodeHostingSpec, *,
-                                            opt_onto: Optional[LocalBranchShortName], opt_yes: bool) -> None:
-
+    def __sync_before_creating_pull_request(self, *, opt_onto: Optional[LocalBranchShortName], opt_yes: bool) -> None:
+        spec = self.code_hosting_spec
         self.expect_at_least_one_managed_branch()
         self._set_empty_line_status()
 
@@ -205,16 +219,15 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 return
             raise MacheteException(f'Interrupted creating {spec.pr_full_name}.')
 
-    def sync_annotations_to_prs(self, spec: CodeHostingSpec, include_urls: bool) -> None:
-        self._init_code_hosting_client(spec)
+    def sync_annotations_to_prs(self, include_urls: bool) -> None:
+        self._init_code_hosting_client()
         current_user: Optional[str] = self.code_hosting_client.get_current_user_login()
-        debug(f'Current {spec.display_name} user is ' + (bold(current_user or '<none>')))
+        debug(f'Current {self.code_hosting_spec.display_name} user is ' + (bold(current_user or '<none>')))
         all_open_prs = self._get_all_open_prs()
-        self.__sync_annotations_to_branch_layout_file(spec, all_open_prs, current_user, include_urls=include_urls, verbose=True)
+        self.__sync_annotations_to_branch_layout_file(all_open_prs, current_user, include_urls=include_urls, verbose=True)
 
     def create_pull_request(
             self,
-            spec: CodeHostingSpec,
             *,
             head: LocalBranchShortName,
             opt_draft: bool,
@@ -225,18 +238,19 @@ class MacheteClientWithCodeHosting(MacheteClient):
     ) -> None:
         # first make sure that head branch is synced with remote
         try:
-            self.__sync_before_creating_pull_request(spec, opt_onto=opt_onto, opt_yes=opt_yes)
+            self.__sync_before_creating_pull_request(opt_onto=opt_onto, opt_yes=opt_yes)
         except InteractionStopped:
             return
 
         base: Optional[LocalBranchShortName] = self.up_branch_for(LocalBranchShortName.of(head))
+        spec = self.code_hosting_spec
         if not base:
             raise UnexpectedMacheteException(f'Could not determine {spec.base_branch_name} branch for {spec.pr_short_name}. '
                                              f'Branch {bold(head)} is a root branch.')
 
-        domain = self.__derive_code_hosting_domain(spec)
-        head_org_repo_remote = self.__derive_org_repo_and_remote(spec, domain=domain, branch_used_for_tracking_data=head)
-        base_org_repo_remote = self.__derive_org_repo_and_remote(spec, domain=domain, branch_used_for_tracking_data=base)
+        domain = self.__derive_code_hosting_domain()
+        head_org_repo_remote = self.__derive_org_repo_and_remote(domain=domain, branch_used_for_tracking_data=head)
+        base_org_repo_remote = self.__derive_org_repo_and_remote(domain=domain, branch_used_for_tracking_data=base)
         debug(f"head_org_repo_remote={head_org_repo_remote}, base_org_repo_remote={base_org_repo_remote}")
 
         base_org_repo = base_org_repo_remote.extract_org_and_repo()
@@ -359,16 +373,17 @@ class MacheteClientWithCodeHosting(MacheteClient):
             self.code_hosting_client.add_reviewers_to_pull_request(pr.number, reviewers)
             print(fmt(ok_str))
 
-        self._state.annotations[head] = Annotation(self._pull_request_annotation(spec, pr, current_user), qualifiers=Qualifiers())
+        self._state.annotations[head] = Annotation(self._pull_request_annotation(pr, current_user), qualifiers=Qualifiers())
         self.save_branch_layout_file()
 
         if opt_update_related_descriptions:
             print(f"Updating descriptions of other {spec.pr_short_name}s...")
-            self.update_pull_request_descriptions(spec, related=True)
+            self.update_pull_request_descriptions(related=True)
 
-    def restack_pull_request(self, spec: CodeHostingSpec, opt_update_related_descriptions: bool) -> None:
+    def restack_pull_request(self, opt_update_related_descriptions: bool) -> None:
+        spec = self.code_hosting_spec
         head = self._git.get_current_branch()
-        _, org_repo_remote = self._init_code_hosting_client(spec, branch_used_for_tracking_data=head)
+        _, org_repo_remote = self._init_code_hosting_client(branch_used_for_tracking_data=head)
 
         pr: Optional[PullRequest] = self.__get_sole_pull_request_for_head(head, ignore_if_missing=False)
         assert pr is not None
@@ -394,7 +409,7 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 print(f'{pr.display_text()} has been temporarily marked as draft')
 
             # Note that retarget should happen BEFORE push, see issue #1222
-            self.retarget_pull_request(spec, head, opt_ignore_if_missing=False,
+            self.retarget_pull_request(head, opt_ignore_if_missing=False,
                                        opt_update_related_descriptions=opt_update_related_descriptions)
 
             if s == SyncToRemoteStatus.AHEAD_OF_REMOTE:
@@ -447,7 +462,7 @@ class MacheteClientWithCodeHosting(MacheteClient):
             else:  # case handled elsewhere
                 raise UnexpectedMacheteException(f"Could not retarget {spec.pr_full_name}: invalid sync-to-remote status `{s}`.")
 
-            self.retarget_pull_request(spec, head, opt_ignore_if_missing=False,
+            self.retarget_pull_request(head, opt_ignore_if_missing=False,
                                        opt_update_related_descriptions=opt_update_related_descriptions)
 
     def _get_updated_pull_request_description(self, pr: PullRequest) -> str:
@@ -471,10 +486,11 @@ class MacheteClientWithCodeHosting(MacheteClient):
             lines = lines_to_prepend + ([''] if lines_to_prepend else []) + skip_leading_empty(lines)
         return '\n'.join(lines) + original_trailing_newlines
 
-    def retarget_pull_request(self, spec: CodeHostingSpec, head: LocalBranchShortName,
+    def retarget_pull_request(self, head: LocalBranchShortName, *,
                               opt_ignore_if_missing: bool, opt_update_related_descriptions: bool) -> None:
+        spec = self.code_hosting_spec
         if self.__code_hosting_client is None:
-            self._init_code_hosting_client(spec, branch_used_for_tracking_data=head)
+            self._init_code_hosting_client(branch_used_for_tracking_data=head)
 
         pr: Optional[PullRequest] = self.__get_sole_pull_request_for_head(
             head, ignore_if_missing=opt_ignore_if_missing)
@@ -504,8 +520,8 @@ class MacheteClientWithCodeHosting(MacheteClient):
 
         current_user: Optional[str] = self.code_hosting_client.get_current_user_login()
         anno = self._state.annotations.get(head)
-        self._state.annotations[head] = Annotation(self._pull_request_annotation(
-            spec, pr, current_user), anno.qualifiers if anno else Qualifiers())
+        qualifiers = anno.qualifiers if anno else Qualifiers()
+        self._state.annotations[head] = Annotation(self._pull_request_annotation(pr, current_user), qualifiers)
         self.save_branch_layout_file()
 
         if opt_update_related_descriptions:
@@ -521,15 +537,16 @@ class MacheteClientWithCodeHosting(MacheteClient):
                     pr.description = new_description
                     print(fmt(f'Description of {pr.display_text()} (<b>{pr.head} {get_right_arrow()} {pr.base}</b>) has been updated'))
 
-    def __derive_code_hosting_domain(self, spec: CodeHostingSpec) -> str:
+    def __derive_code_hosting_domain(self) -> str:
+        spec = self.code_hosting_spec
         return self._git.get_config_attr_or_none(key=spec.git_config_keys.domain) or spec.default_domain
 
     def __derive_org_repo_and_remote(
             self,
-            spec: CodeHostingSpec,
             domain: str,
             branch_used_for_tracking_data: Optional[LocalBranchShortName] = None
     ) -> OrganizationAndRepositoryAndRemote:
+        spec = self.code_hosting_spec
         remote_config_key = spec.git_config_keys.remote
         organization_config_key = spec.git_config_keys.organization
         repository_config_key = spec.git_config_keys.repository
@@ -613,15 +630,15 @@ class MacheteClientWithCodeHosting(MacheteClient):
             f'You can select the {spec.repository_name} by providing some or all of git config keys:\n'
             f'{spec.git_config_keys.for_locating_repo_message()}\n')
 
-    def _init_code_hosting_client(self, spec: CodeHostingSpec,
+    def _init_code_hosting_client(self,
                                   branch_used_for_tracking_data: Optional[LocalBranchShortName] = None
                                   ) -> Tuple[str, OrganizationAndRepositoryAndRemote]:
         if self.__code_hosting_client is not None:
             raise UnexpectedMacheteException("Code hosting client has already been initialized.")
-        domain = self.__derive_code_hosting_domain(spec)
+        domain = self.__derive_code_hosting_domain()
         org_repo_remote = self.__derive_org_repo_and_remote(
-            spec, domain=domain, branch_used_for_tracking_data=branch_used_for_tracking_data)
-        self.code_hosting_client = spec.create_client(
+            domain=domain, branch_used_for_tracking_data=branch_used_for_tracking_data)
+        self.code_hosting_client = self.code_hosting_spec.create_client(
             domain=domain, organization=org_repo_remote.organization, repository=org_repo_remote.repository)
         return domain, org_repo_remote
 
@@ -727,11 +744,11 @@ class MacheteClientWithCodeHosting(MacheteClient):
         prepend += f'{self.END_GIT_MACHETE_GENERATED_COMMENT}\n'
         return prepend
 
-    def update_pull_request_descriptions(self, spec: CodeHostingSpec,
-                                         *, all: bool = False, by: Optional[str] = None, mine: bool = False, related: bool = False
+    def update_pull_request_descriptions(self, *, all: bool = False, by: Optional[str] = None, mine: bool = False, related: bool = False
                                          ) -> None:
+        spec = self.code_hosting_spec
         if self.__code_hosting_client is None:
-            self._init_code_hosting_client(spec)
+            self._init_code_hosting_client()
 
         current_user: Optional[str] = self.code_hosting_client.get_current_user_login()
         if not current_user and mine:
@@ -755,7 +772,6 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 print(fmt(f'Description of {pr.display_text()} (<b>{pr.head} {get_right_arrow()} {pr.base}</b>) has been updated'))
 
     def checkout_pull_requests(self,
-                               spec: CodeHostingSpec,
                                pr_numbers: Optional[List[int]],
                                *,
                                all: bool = False,
@@ -763,7 +779,8 @@ class MacheteClientWithCodeHosting(MacheteClient):
                                by: Optional[str] = None,
                                fail_on_missing_current_user_for_my_open_prs: bool = False
                                ) -> None:
-        domain, org_repo_remote = self._init_code_hosting_client(spec)
+        spec = self.code_hosting_spec
+        domain, org_repo_remote = self._init_code_hosting_client()
 
         current_user: Optional[str] = self.code_hosting_client.get_current_user_login()
         if not current_user and mine:
@@ -840,8 +857,7 @@ class MacheteClientWithCodeHosting(MacheteClient):
                     print(fmt(f"{pr_on_path.display_text()} checked out at local branch {bold(pr_on_path.head)}"))
 
         debug(f'Current {spec.display_name} user is ' + (current_user or '<none>'))
-        self.__sync_annotations_to_branch_layout_file(spec, list(prs_to_annotate),
-                                                      current_user=current_user, include_urls=False, verbose=False)
+        self.__sync_annotations_to_branch_layout_file(list(prs_to_annotate), current_user=current_user, include_urls=False, verbose=False)
         if len(applicable_prs) == 1:
             self._git.checkout(LocalBranchShortName.of(applicable_prs[0].head))
 
diff --git a/git_machete/code_hosting.py b/git_machete/code_hosting.py
index 13d80bf..1440f76 100644
--- a/git_machete/code_hosting.py
+++ b/git_machete/code_hosting.py
@@ -186,14 +186,13 @@ class CodeHostingSpec(NamedTuple):
         return f"CodeHostingSpec({self.display_name})"
 
     def create_client(self, domain: str, organization: str, repository: str) -> "CodeHostingClient":
-        return self.client_class(self, domain=domain, organization=organization, repository=repository)  # type: ignore[no-any-return]
+        return self.client_class(domain=domain, organization=organization, repository=repository)  # type: ignore[no-any-return]
 
 
 # flake8: noqa
 # So that flake8 doesn't complain about unused params in abstract class.
 class CodeHostingClient(metaclass=ABCMeta):  # pragma: no cover
-    def __init__(self, spec: CodeHostingSpec, domain: str, organization: str, repository: str) -> None:
-        self._spec: CodeHostingSpec = spec
+    def __init__(self, domain: str, organization: str, repository: str) -> None:
         self.domain: str = domain
         self.organization: str = organization
         self.repository: str = repository
diff --git a/git_machete/github.py b/git_machete/github.py
index 16cc275..770803b 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -149,44 +149,8 @@ class GitHubClient(CodeHostingClient):
     # As of Dec 2022, GitHub API never returns more than 100 PRs, even if per_page query param is above 100.
     MAX_PULLS_PER_PAGE_COUNT = 100
 
-    @classmethod
-    def spec(cls) -> CodeHostingSpec:
-        return CodeHostingSpec(
-            base_branch_name='base',
-            client_class=cls,
-            default_domain=cls.DEFAULT_GITHUB_DOMAIN,
-            display_name='GitHub',
-            git_machete_command='github',
-            head_branch_name='head',
-            organization_name='organization',
-            # https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
-            pr_description_path=['.github', 'pull_request_template.md'],
-            pr_full_name='pull request',
-            pr_intro_br_before_branches=False,
-            pr_intro_explicit_title=False,
-            pr_ordinal_char='#',
-            pr_short_name='PR',
-            pr_short_name_article='a',
-            repository_name='repository',
-            token_providers_message=(
-                f'\n\t1. `{GITHUB_TOKEN_ENV_VAR}` environment variable\n'
-                '\t2. Content of the `~/.github-token` file\n'
-                '\t3. Current auth token from the `gh` GitHub CLI\n'
-                '\t4. Current auth token from the `hub` GitHub CLI\n'
-            ),
-            git_config_keys=CodeHostingGitConfigKeys(
-                domain='machete.github.domain',
-                organization='machete.github.organization',
-                repository='machete.github.repository',
-                remote='machete.github.remote',
-                annotate_with_urls='machete.github.annotateWithUrls',
-                force_description_from_commit_message='machete.github.forceDescriptionFromCommitMessage',
-                pr_description_intro_style='machete.github.prDescriptionIntroStyle',
-            )
-        )
-
-    def __init__(self, spec: CodeHostingSpec, domain: str, organization: str, repository: str) -> None:
-        super().__init__(spec, domain, organization, repository)
+    def __init__(self, domain: str, organization: str, repository: str) -> None:
+        super().__init__(domain, organization, repository)
         self.__token: Optional[GitHubToken] = GitHubToken.for_domain(domain)
 
     def __get_pull_request_from_json(self, pr_json: Dict[str, Any]) -> "PullRequest":
@@ -268,7 +232,7 @@ class GitHubClient(CodeHostingClient):
                 # TODO (#164): make a dedicated exception here
                 raise MacheteException(
                     f'`{method} {url}` request ended up in 404 response from GitHub. A valid GitHub API token is required.\n'
-                    f'Provide a GitHub API token with `repo` access via one of the: {self._spec.token_providers_message} '
+                    f'Provide a GitHub API token with `repo` access via one of the: {GITHUB_CLIENT_SPEC.token_providers_message} '
                     f'Visit `https://{self.domain}/settings/tokens` to generate a new one.')
             # See https://stackoverflow.com/a/62385184 for why 307 for POST/PATCH isn't automatically followed by urllib,
             # unlike 307 for GET, or 301/302 for all HTTP methods.
@@ -451,3 +415,38 @@ class GitHubClient(CodeHostingClient):
     def get_ref_name_for_pull_request(self, pr_number: int) -> str:
         # See `git ls-remote` for any GitHub remote.
         return f"pull/{pr_number}/head"
+
+
+GITHUB_CLIENT_SPEC = CodeHostingSpec(
+    base_branch_name='base',
+    client_class=GitHubClient,
+    default_domain=GitHubClient.DEFAULT_GITHUB_DOMAIN,
+    display_name='GitHub',
+    git_machete_command='github',
+    head_branch_name='head',
+    organization_name='organization',
+    # https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
+    pr_description_path=['.github', 'pull_request_template.md'],
+    pr_full_name='pull request',
+    pr_intro_br_before_branches=False,
+    pr_intro_explicit_title=False,
+    pr_ordinal_char='#',
+    pr_short_name='PR',
+    pr_short_name_article='a',
+    repository_name='repository',
+    token_providers_message=(
+        f'\n\t1. `{GITHUB_TOKEN_ENV_VAR}` environment variable\n'
+        '\t2. Content of the `~/.github-token` file\n'
+        '\t3. Current auth token from the `gh` GitHub CLI\n'
+        '\t4. Current auth token from the `hub` GitHub CLI\n'
+    ),
+    git_config_keys=CodeHostingGitConfigKeys(
+        domain='machete.github.domain',
+        organization='machete.github.organization',
+        repository='machete.github.repository',
+        remote='machete.github.remote',
+        annotate_with_urls='machete.github.annotateWithUrls',
+        force_description_from_commit_message='machete.github.forceDescriptionFromCommitMessage',
+        pr_description_intro_style='machete.github.prDescriptionIntroStyle',
+    )
+)
diff --git a/git_machete/gitlab.py b/git_machete/gitlab.py
index a72ac82..1211af4 100644
--- a/git_machete/gitlab.py
+++ b/git_machete/gitlab.py
@@ -97,49 +97,10 @@ class GitLabClient(CodeHostingClient):
     DEFAULT_GITLAB_DOMAIN = "gitlab.com"
     MAX_PULLS_PER_PAGE_COUNT = 100
 
-    def __init__(self, spec: CodeHostingSpec, domain: str, organization: str, repository: str) -> None:
-        super().__init__(spec, domain, organization, repository)
+    def __init__(self, domain: str, organization: str, repository: str) -> None:
+        super().__init__(domain, organization, repository)
         self.__token: Optional[GitLabToken] = GitLabToken.for_domain(domain)
 
-    @classmethod
-    def spec(cls) -> CodeHostingSpec:
-        return CodeHostingSpec(
-            base_branch_name='target',
-            client_class=cls,
-            default_domain=cls.DEFAULT_GITLAB_DOMAIN,
-            display_name='GitLab',
-            git_machete_command='gitlab',
-            head_branch_name='source',
-            organization_name='namespace',
-            # https://docs.gitlab.com/ee/user/project/description_templates.html#create-a-merge-request-template
-            # https://docs.gitlab.com/ee/user/project/description_templates.html#set-a-default-template-for-merge-requests-and-issues
-            # Actual MR template resolution for GitLab has a complex hierarchy of templates, including
-            # project-level, group-level and instance-level templates - and also the ability to "choose" a template.
-            # To keep things simple, we'll only support the "Default.md" template for now.
-            pr_description_path=['.gitlab', 'merge_request_templates', 'Default.md'],
-            pr_full_name='merge request',
-            pr_intro_br_before_branches=True,
-            pr_intro_explicit_title=True,
-            pr_ordinal_char='!',
-            pr_short_name='MR',
-            pr_short_name_article='an',
-            repository_name='project',
-            token_providers_message=(
-                f'\n\t1. `{GITLAB_TOKEN_ENV_VAR}` environment variable\n'
-                '\t2. Content of the `~/.gitlab-token` file\n'
-                '\t3. Current auth token from the `glab` GitLab CLI\n'
-            ),
-            git_config_keys=CodeHostingGitConfigKeys(
-                domain='machete.gitlab.domain',
-                organization='machete.gitlab.namespace',
-                repository='machete.gitlab.project',
-                remote='machete.gitlab.remote',
-                annotate_with_urls='machete.gitlab.annotateWithUrls',
-                force_description_from_commit_message='machete.gitlab.forceDescriptionFromCommitMessage',
-                pr_description_intro_style='machete.gitlab.mrDescriptionIntroStyle',
-            )
-        )
-
     @staticmethod
     def __get_merge_request_from_json(mr_json: Dict[str, Any]) -> PullRequest:
         return PullRequest(
@@ -209,7 +170,7 @@ class GitLabClient(CodeHostingClient):
                 # TODO (#164): make a dedicated exception here
                 raise MacheteException(
                     f'`{method} {url}` request ended up in 404 response from GitLab. A valid GitLab API token is required.\n'
-                    f'Provide a GitLab API token with `api` access via one of the: {self._spec.token_providers_message} '
+                    f'Provide a GitLab API token with `api` access via one of the: {GITLAB_CLIENT_SPEC.token_providers_message} '
                     f'Visit `https://{self.domain}/-/user_settings/personal_access_tokens` to generate a new one.')
             elif err.code == http.HTTPStatus.METHOD_NOT_ALLOWED:
                 error_response = json.loads(err.read().decode())
@@ -360,3 +321,41 @@ class GitLabClient(CodeHostingClient):
     def get_ref_name_for_pull_request(self, mr_number: int) -> str:
         # See `git ls-remote` for any GitLab remote.
         return f"merge-requests/{mr_number}/head"
+
+
+GITLAB_CLIENT_SPEC = CodeHostingSpec(
+    base_branch_name='target',
+    client_class=GitLabClient,
+    default_domain=GitLabClient.DEFAULT_GITLAB_DOMAIN,
+    display_name='GitLab',
+    git_machete_command='gitlab',
+    head_branch_name='source',
+    organization_name='namespace',
+    # https://docs.gitlab.com/ee/user/project/description_templates.html#create-a-merge-request-template
+    # https://docs.gitlab.com/ee/user/project/description_templates.html#set-a-default-template-for-merge-requests-and-issues
+    # Actual MR template resolution for GitLab has a complex hierarchy of templates, including
+    # project-level, group-level and instance-level templates - and also the ability to "choose" a template.
+    # To keep things simple, we'll only support the "Default.md" template for now.
+    pr_description_path=['.gitlab', 'merge_request_templates', 'Default.md'],
+    pr_full_name='merge request',
+    pr_intro_br_before_branches=True,
+    pr_intro_explicit_title=True,
+    pr_ordinal_char='!',
+    pr_short_name='MR',
+    pr_short_name_article='an',
+    repository_name='project',
+    token_providers_message=(
+        f'\n\t1. `{GITLAB_TOKEN_ENV_VAR}` environment variable\n'
+        '\t2. Content of the `~/.gitlab-token` file\n'
+        '\t3. Current auth token from the `glab` GitLab CLI\n'
+    ),
+    git_config_keys=CodeHostingGitConfigKeys(
+        domain='machete.gitlab.domain',
+        organization='machete.gitlab.namespace',
+        repository='machete.gitlab.project',
+        remote='machete.gitlab.remote',
+        annotate_with_urls='machete.gitlab.annotateWithUrls',
+        force_description_from_commit_message='machete.gitlab.forceDescriptionFromCommitMessage',
+        pr_description_intro_style='machete.gitlab.mrDescriptionIntroStyle',
+    )
+)


Test patch:
diff --git a/tests/test_github.py b/tests/test_github.py
index 92a3f36..8624716 100644
--- a/tests/test_github.py
+++ b/tests/test_github.py
@@ -29,7 +29,7 @@ class TestGitHub(BaseTest):
 
     def test_github_client_constructor(self) -> None:
         # This is solely to make mypy check if the class correctly implements abstract methods from CodeHostingClient.
-        GitHubClient(GitHubClient.spec(), "github.com", "my-org", "my-repo")
+        GitHubClient("github.com", "my-org", "my-repo")
 
     def test_github_remote_patterns(self) -> None:
         organization = 'virtuslab'
diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py
index 509e16e..e4e5634 100644
--- a/tests/test_gitlab.py
+++ b/tests/test_gitlab.py
@@ -28,7 +28,7 @@ class TestGitLab(BaseTest):
 
     def test_gitlab_client_constructor(self) -> None:
         # This is solely to make mypy check if the class correctly implements abstract methods from CodeHostingClient.
-        GitLabClient(GitLabClient.spec(), "gitlab.com", "my-org", "my-repo")
+        GitLabClient("gitlab.com", "my-org", "my-repo")
 
     def test_gitlab_remote_patterns(self) -> None:
         organization = 'virtuslab'