diff --git a/README.md b/README.md index 55e7e538a..8f094682a 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,6 @@ nix-env -i git-machete The latest released version, however, is generally available in the unstable channel. Stable channels may lag behind; see [repology](https://repology.org/project/git-machete/versions) for the current channel-package mapping. -
- ### Using Pex The [Pex tool](https://github.com/pex-tool/pex) (short for Python EXecutable) allows you to build "pex" files which are executable Python environments in a single file. @@ -141,6 +139,8 @@ pex git-machete -m git_machete.bin:main -o git-machete Then put the produced `git-machete` file somewhere on your `PATH`. +
+ ## Quick start ### Discover the branch layout diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e6049a035..e856b377a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,11 @@ # Release notes -## New in git-machete 3.28.1 +## New in git-machete 3.29.0 + +- added: git config keys `machete.github.prDescriptionIntroStyle` and `machete.gitlab.mrDescriptionIntroStyle` +- added: ability to turn off PR/MR description intro completely by setting the git config keys to `none` (suggested by @tir38) +- added: ability to also include downstream PRs/MRs in PR/MR description intro (suggested by @aouaki) +- changed: layout and ordering of PRs/MRs in PR/MR description intro to better match `git machete status` ## New in git-machete 3.28.0 diff --git a/ci/checks/prohibit-trailing-whitespace.sh b/ci/checks/prohibit-trailing-whitespace.sh index 4504d4292..99c49ef0d 100755 --- a/ci/checks/prohibit-trailing-whitespace.sh +++ b/ci/checks/prohibit-trailing-whitespace.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -if git grep -EIn ' +$' -- :!'*.py'; then +if git grep -EIn ' +$' -- :!git_machete/generated_docs.py; then echo 'The above lines contain trailing whitespace, please tidy up' exit 1 fi diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1 index 07a790953..cec4a36d2 100644 --- a/docs/man/git-machete.1 +++ b/docs/man/git-machete.1 @@ -27,9 +27,9 @@ 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" "Aug 22, 2024" "" "git-machete" +.TH "GIT-MACHETE" "1" "Aug 23, 2024" "" "git-machete" .SH NAME -git-machete \- git-machete 3.28.1 +git-machete \- git-machete 3.29.0 .sp git machete is a robust tool that \fBsimplifies your git workflows\fP\&. .sp @@ -360,6 +360,12 @@ but also \fBgithub checkout\-prs\fP, \fBgithub create\-pr\fP, \fBgithub retarget Setting this config key to \fBtrue\fP will force \fBgit machete github create\-pr\fP to take PR description from the message body of the first unique commit of the branch, even if \fB\&.git/info/description\fP and/or \fB\&.github/pull_request_template.md\fP is present. .TP +.B \fBmachete.github.prDescriptionIntroStyle\fP: +Select the style of the intro prepended to PR description: +* \fBfull\fP \-\-\- include both a chain of upstream PRs (typically leading to \fBmain\fP, \fBmaster\fP, \fBdevelop\fP etc.) and a tree of downstream PRs +* \fBup\-only\fP \-\-\- default, include only a chain of upstream PRs +* \fBnone\fP \-\-\- prepend no intro to the PR description at all +.TP .B \fBmachete.gitlab.{domain,remote,namespace,project}\fP: .INDENT 7.0 .INDENT 3.5 @@ -392,6 +398,12 @@ but also \fBgitlab checkout\-mrs\fP, \fBgitlab create\-mr\fP, \fBgitlab retarget Setting this config key to \fBtrue\fP will force \fBgit machete gitlab create\-mr\fP to take MR description from the message body of the first unique commit of the branch, even if \fB\&.git/info/description\fP and/or \fB\&.gitlab/merge_request_templates/Default.md\fP is present. .TP +.B \fBmachete.gitlab.mrDescriptionIntroStyle\fP: +Select the style of the intro prepended to MR description: +* \fBfull\fP \-\-\- include both a chain of upstream MRs (typically leading to \fBmain\fP, \fBmaster\fP, \fBdevelop\fP etc.) and a tree of downstream MRs +* \fBup\-only\fP \-\-\- default, include only a chain of upstream MRs +* \fBnone\fP \-\-\- prepend no intro to the MR description at all +.TP .B \fBmachete.overrideForkPoint..to\fP: Executing \fBgit machete fork\-point \-\-override\-to[\-parent|\-inferred|=] []\fP sets up a fork point override for \fB\fP\&. .sp @@ -1009,10 +1021,10 @@ Draft PRs don\(aqt get code owners automatically added as reviewers. .B \fBretarget\-pr [\-b|\-\-branch=] [\-\-ignore\-if\-missing]\fP: Sets the base of the current (or specified) branch\(aqs PR to upstream (parent) branch, as seen by git machete (see \fBgit machete show up\fP). .sp -If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended with a section -listing the entire related chain of PRs. +If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended +with an intro section listing the entire related chain of PRs. .sp -This header will be updated or removed accordingly with the subsequent runs of \fBretarget\-pr\fP\&. +This header will be updated or removed accordingly with the subsequent runs of \fBretarget\-pr\fP, even if the base branch is already up to date. .sp \fBOptions:\fP .INDENT 7.0 @@ -1075,6 +1087,12 @@ but also \fBgithub checkout\-prs\fP, \fBgithub create\-pr\fP, \fBgithub retarget .B \fBmachete.github.forceDescriptionFromCommitMessage\fP (\fBcreate\-pr\fP only): Setting this config key to \fBtrue\fP will force \fBgit machete github create\-pr\fP to take PR description from the message body of the first unique commit of the branch, even if \fB\&.git/info/description\fP and/or \fB\&.github/pull_request_template.md\fP is present. +.TP +.B \fBmachete.github.prDescriptionIntroStyle\fP (\fBcreate\-pr\fP, \fBrestack\-pr\fP and \fBretarget\-pr\fP): +Select the style of the intro prepended to PR description: +* \fBfull\fP \-\-\- include both a chain of upstream PRs (typically leading to \fBmain\fP, \fBmaster\fP, \fBdevelop\fP etc.) and a tree of downstream PRs +* \fBup\-only\fP \-\-\- default, include only a chain of upstream PRs +* \fBnone\fP \-\-\- prepend no intro to the PR description at all .UNINDENT .sp \fBEnvironment variables (all subcommands):\fP @@ -1233,10 +1251,10 @@ Draft MRs don\(aqt get code owners automatically added as reviewers. .B \fBretarget\-mr [\-b|\-\-branch=] [\-\-ignore\-if\-missing]\fP: Sets the target of the current (or specified) branch\(aqs MR to upstream (parent) branch, as seen by git machete (see \fBgit machete show up\fP). .sp -If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended with a section -listing the entire related chain of MRs. +If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended +with an intro section listing the entire related chain of MRs. .sp -This header will be updated or removed accordingly with the subsequent runs of \fBretarget\-mr\fP\&. +This header will be updated or removed accordingly with the subsequent runs of \fBretarget\-mr\fP, even if the target branch is already up to date. .sp \fBOptions:\fP .INDENT 7.0 @@ -1285,6 +1303,12 @@ but also \fBgitlab checkout\-mrs\fP, \fBgitlab create\-mr\fP, \fBgitlab retarget .B \fBmachete.gitlab.forceDescriptionFromCommitMessage\fP (\fBcreate\-mr\fP only): Setting this config key to \fBtrue\fP will force \fBgit machete gitlab create\-mr\fP to take MR description from the message body of the first unique commit of the branch, even if \fB\&.git/info/description\fP and/or \fB\&.gitlab/merge_request_templates/Default.md\fP is present. +.TP +.B \fBmachete.gitlab.mrDescriptionIntroStyle\fP (\fBcreate\-mr\fP, \fBrestack\-mr\fP and \fBretarget\-mr\fP): +Select the style of the intro prepended to PR description: +* \fBfull\fP \-\-\- include both a chain of upstream PRs (typically leading to \fBmain\fP, \fBmaster\fP, \fBdevelop\fP etc.) and a tree of downstream PRs +* \fBup\-only\fP \-\-\- default, include only a chain of upstream PRs +* \fBnone\fP \-\-\- prepend no intro to the PR description at all .UNINDENT .sp \fBEnvironment variables (all subcommands):\fP diff --git a/docs/source/cli/config.rst b/docs/source/cli/config.rst index 6c3e93ff7..cf9b26b28 100644 --- a/docs/source/cli/config.rst +++ b/docs/source/cli/config.rst @@ -18,6 +18,9 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git ``machete.github.forceDescriptionFromCommitMessage``: .. include:: git-config-keys/github_forceDescriptionFromCommitMessage.rst +``machete.github.prDescriptionIntroStyle``: + .. include:: git-config-keys/github_prDescriptionIntroStyle.rst + ``machete.gitlab.{domain,remote,namespace,project}``: .. include:: git-config-keys/gitlab_access.rst :start-line: 3 @@ -28,6 +31,9 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git ``machete.gitlab.forceDescriptionFromCommitMessage``: .. include:: git-config-keys/gitlab_forceDescriptionFromCommitMessage.rst +``machete.gitlab.mrDescriptionIntroStyle``: + .. include:: git-config-keys/gitlab_mrDescriptionIntroStyle.rst + ``machete.overrideForkPoint..to``: Executing ``git machete fork-point --override-to[-parent|-inferred|=] []`` sets up a fork point override for ````. diff --git a/docs/source/cli/github.rst b/docs/source/cli/github.rst index f76593769..c330710b4 100644 --- a/docs/source/cli/github.rst +++ b/docs/source/cli/github.rst @@ -112,10 +112,10 @@ Creates, checks out and manages GitHub PRs while keeping them reflected in branc ``retarget-pr [-b|--branch=] [--ignore-if-missing]``: Sets the base of the current (or specified) branch's PR to upstream (parent) branch, as seen by git machete (see ``git machete show up``). - If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended with a section - listing the entire related chain of PRs. + If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended + with an intro section listing the entire related chain of PRs. - This header will be updated or removed accordingly with the subsequent runs of ``retarget-pr``. + This header will be updated or removed accordingly with the subsequent runs of ``retarget-pr``, even if the base branch is already up to date. **Options:** @@ -144,6 +144,9 @@ Creates, checks out and manages GitHub PRs while keeping them reflected in branc ``machete.github.forceDescriptionFromCommitMessage`` (``create-pr`` only): .. include:: git-config-keys/github_forceDescriptionFromCommitMessage.rst +``machete.github.prDescriptionIntroStyle`` (``create-pr``, ``restack-pr`` and ``retarget-pr``): + .. include:: git-config-keys/github_prDescriptionIntroStyle.rst + **Environment variables (all subcommands):** ``GITHUB_TOKEN`` diff --git a/docs/source/cli/gitlab.rst b/docs/source/cli/gitlab.rst index 535ef7bb8..65eb230c6 100644 --- a/docs/source/cli/gitlab.rst +++ b/docs/source/cli/gitlab.rst @@ -113,10 +113,10 @@ Creates, checks out and manages GitLab MRs while keeping them reflected in branc ``retarget-mr [-b|--branch=] [--ignore-if-missing]``: Sets the target of the current (or specified) branch's MR to upstream (parent) branch, as seen by git machete (see ``git machete show up``). - If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended with a section - listing the entire related chain of MRs. + If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended + with an intro section listing the entire related chain of MRs. - This header will be updated or removed accordingly with the subsequent runs of ``retarget-mr``. + This header will be updated or removed accordingly with the subsequent runs of ``retarget-mr``, even if the target branch is already up to date. **Options:** @@ -135,6 +135,9 @@ Creates, checks out and manages GitLab MRs while keeping them reflected in branc ``machete.gitlab.forceDescriptionFromCommitMessage`` (``create-mr`` only): .. include:: git-config-keys/gitlab_forceDescriptionFromCommitMessage.rst +``machete.gitlab.mrDescriptionIntroStyle`` (``create-mr``, ``restack-mr`` and ``retarget-mr``): + .. include:: git-config-keys/github_prDescriptionIntroStyle.rst + **Environment variables (all subcommands):** ``GITLAB_TOKEN`` diff --git a/docs/source/git-config-keys/github_prDescriptionIntroStyle.rst b/docs/source/git-config-keys/github_prDescriptionIntroStyle.rst new file mode 100644 index 000000000..c104e1d19 --- /dev/null +++ b/docs/source/git-config-keys/github_prDescriptionIntroStyle.rst @@ -0,0 +1,4 @@ +Select the style of the intro prepended to PR description: +* ``full`` --- include both a chain of upstream PRs (typically leading to ``main``, ``master``, ``develop`` etc.) and a tree of downstream PRs +* ``up-only`` --- default, include only a chain of upstream PRs +* ``none`` --- prepend no intro to the PR description at all diff --git a/docs/source/git-config-keys/gitlab_mrDescriptionIntroStyle.rst b/docs/source/git-config-keys/gitlab_mrDescriptionIntroStyle.rst new file mode 100644 index 000000000..93e9bd881 --- /dev/null +++ b/docs/source/git-config-keys/gitlab_mrDescriptionIntroStyle.rst @@ -0,0 +1,4 @@ +Select the style of the intro prepended to MR description: +* ``full`` --- include both a chain of upstream MRs (typically leading to ``main``, ``master``, ``develop`` etc.) and a tree of downstream MRs +* ``up-only`` --- default, include only a chain of upstream MRs +* ``none`` --- prepend no intro to the MR description at all diff --git a/git_machete/__init__.py b/git_machete/__init__.py index d5631d736..c9c983224 100644 --- a/git_machete/__init__.py +++ b/git_machete/__init__.py @@ -1 +1 @@ -__version__ = '3.28.1' +__version__ = '3.29.0' diff --git a/git_machete/client.py b/git_machete/client.py index c0d951c0f..ef23a274c 100644 --- a/git_machete/client.py +++ b/git_machete/client.py @@ -8,7 +8,7 @@ import textwrap from collections import OrderedDict from enum import Enum, auto -from typing import (Callable, Dict, Iterator, List, Optional, Tuple, Type, +from typing import (Callable, Dict, Iterator, List, Optional, Set, Tuple, Type, TypeVar) from . import git_config_keys, utils @@ -63,9 +63,10 @@ def from_string(cls: Type[E], value: str, from_where: Optional[str]) -> E: try: return cls[value.upper().replace("-", "_")] except KeyError: - valid_values = ', '.join(e.name.lower().replace("_", "-") for e in cls) + valid_values = ', '.join('`' + e.name.lower().replace("_", "-") + '`' for e in cls) prefix = f"Invalid value for {from_where}" if from_where else "Invalid value" - raise MacheteException(f"{prefix}: `{value}`. Valid values are `{valid_values}`") + printed_value = value or '' + raise MacheteException(f"{prefix}: `{printed_value}`. Valid values are {valid_values}") class PickRoot(Enum): @@ -73,6 +74,12 @@ class PickRoot(Enum): LAST = auto() +class PRDescriptionIntroStyle(ParsableEnum): + FULL = auto() + UP_ONLY = auto() + NONE = auto() + + class SquashMergeDetection(ParsableEnum): NONE = auto() SIMPLE = auto() @@ -2220,7 +2227,7 @@ def checkout_pull_requests(self, warn(f'{pr.display_text()} is already closed.') debug(f'found {pr}') - pr_path: List[PullRequest] = self.__get_path_from_pr_chain(spec, pr, all_open_prs) + pr_path: List[PullRequest] = self.__get_upwards_path_including_pr(spec, pr, all_open_prs) prs_to_annotate.update(pr_path) reversed_pr_path: List[PullRequest] = pr_path[::-1] # need to add from root downwards if reversed_pr_path[0].base not in self.managed_branches: @@ -2251,7 +2258,24 @@ def checkout_pull_requests(self, self.__git.checkout(LocalBranchShortName.of(applicable_prs[0].head)) @staticmethod - def __get_path_from_pr_chain(spec: CodeHostingSpec, original_pr: PullRequest, all_open_prs: List[PullRequest]) -> List[PullRequest]: + def __get_downwards_tree_excluding_pr(original_pr: PullRequest, + all_open_prs: List[PullRequest]) -> List[Tuple[PullRequest, int]]: + """Returns pairs of (PR, level below the given PR)""" + + visited_head_branches: Set[str] = set([]) + + def reverse_pr_dfs(pr: PullRequest, depth: int) -> Iterator[Tuple[PullRequest, int]]: + visited_head_branches.add(pr.head) + down_prs = filter(lambda x: x.base == pr.head, all_open_prs) + for down_pr in sorted(down_prs, key=lambda x: x.number): + if down_pr.head not in visited_head_branches: + yield (down_pr, depth + 1) + yield from reverse_pr_dfs(down_pr, depth + 1) + return list(reverse_pr_dfs(original_pr, 0)) + + @staticmethod + def __get_upwards_path_including_pr(spec: CodeHostingSpec, original_pr: PullRequest, + all_open_prs: List[PullRequest]) -> List[PullRequest]: visited_head_branches: List[str] = [original_pr.head] path: List[PullRequest] = [original_pr] pr_base: Optional[str] = original_pr.base @@ -2432,7 +2456,8 @@ def skip_leading_empty(strs: List[str]) -> List[str]: return list(itertools.dropwhile(lambda line: line.strip() == '', strs)) lines = skip_leading_empty(old_description.splitlines()) if old_description else [] - text_to_prepend = self.__generate_text_to_prepend_to_pr_description(code_hosting_client, pr) + style = self.__get_pr_description_into_style_from_config(code_hosting_client._spec) + text_to_prepend = self.__generate_pr_description_intro(code_hosting_client, pr, style) lines_to_prepend = text_to_prepend.splitlines() if text_to_prepend else [] if self.START_GIT_MACHETE_GENERATED_COMMENT in lines and self.END_GIT_MACHETE_GENERATED_COMMENT in lines: start_index = lines.index(self.START_GIT_MACHETE_GENERATED_COMMENT) @@ -2592,31 +2617,70 @@ def __derive_org_repo_and_remote( START_GIT_MACHETE_GENERATED_COMMENT = '' END_GIT_MACHETE_GENERATED_COMMENT = '' - def __generate_text_to_prepend_to_pr_description(self, code_hosting_client: CodeHostingClient, pr: PullRequest) -> str: + def __get_pr_description_into_style_from_config(self, spec: CodeHostingSpec) -> PRDescriptionIntroStyle: + config_key = spec.git_config_keys.pr_description_intro_style + return PRDescriptionIntroStyle.from_string( + value=self.__git.get_config_attr(key=config_key, default_value="up-only"), + from_where=f"`{config_key}` git config key" + ) - prs_for_base_branch = code_hosting_client.get_open_pull_requests_by_head(LocalBranchShortName(pr.base)) - if len(prs_for_base_branch) < 1: + def __generate_pr_description_intro(self, code_hosting_client: CodeHostingClient, + pr: PullRequest, style: PRDescriptionIntroStyle) -> str: + if style == PRDescriptionIntroStyle.NONE: return '' + # For determining the PR chain, we need to fetch all PRs from the repo. # We could just fetch them straight away... but this list can be quite long for commercial monorepos, - # esp. given that GitHub and GitLab limit the single page to 100 PRs (so multiple HTTP requests would be needed). - # As a slight optimization, let's fetch the full PR list only if the current PR has a base PR at all. - config = code_hosting_client._spec - display_name = config.display_name - pr_short_name = config.pr_short_name - print(f'Checking for open {display_name} {pr_short_name}s (to determine {pr_short_name} chain)... ', end='', flush=True) + # esp. given that GitHub and GitLab limit the single page to 100 PRs (so multiple HTTP requests may be needed). + # As a slight optimization, in the default UP_ONLY style, + # let's fetch the full PR list only if the current PR has a base PR at all. + # In FULL style, we need to check for downstream PRs as well, so the full PR list needs to be fetched anyway. + # That's also the performance reason behind selecting UP_ONLY and not FULL as the default style. + prs_for_base_branch = code_hosting_client.get_open_pull_requests_by_head(LocalBranchShortName(pr.base)) + if style == PRDescriptionIntroStyle.UP_ONLY and len(prs_for_base_branch) == 0: + return '' + spec = code_hosting_client._spec + display_name = spec.display_name + pr_short_name = spec.pr_short_name + determine_what = 'chain' if style == PRDescriptionIntroStyle.UP_ONLY else 'tree' + print(f'Checking for open {display_name} {pr_short_name}s (to determine {pr_short_name} {determine_what})... ', end='', flush=True) all_open_prs: List[PullRequest] = code_hosting_client.get_open_pull_requests() print(fmt('OK')) - pr_path = self.__get_path_from_pr_chain(config, pr, all_open_prs) + pr_up_path = reversed(self.__get_upwards_path_including_pr(spec, pr, all_open_prs)) + if style == PRDescriptionIntroStyle.FULL: + pr_down_tree = self.__get_downwards_tree_excluding_pr(pr, all_open_prs) + else: + pr_down_tree = [] prepend = f'{self.START_GIT_MACHETE_GENERATED_COMMENT}\n\n' - prepend += f'# Based on {prs_for_base_branch[0].display_text(fmt=False)}\n\n' + # In FULL mode, we're likely to generate the intro even when there are NO upstream PRs above + if len(prs_for_base_branch) >= 1: + prepend += f'# Based on {prs_for_base_branch[0].display_text(fmt=False)}\n\n' + + if pr_down_tree: + prepend += f'## Chain of upstream {pr_short_name}s & tree of downstream {pr_short_name}s' + else: + prepend += f'## Chain of upstream {pr_short_name}s' current_date = utils.get_current_date() - prepend += f'## Full chain of {pr_short_name}s as of {current_date}\n\n' - for ancestor_pr in pr_path: - prepend += f'* {ancestor_pr.display_text(fmt=False)}:\n' - prepend += f' `{ancestor_pr.head}` ➔ `{ancestor_pr.base}`\n' - prepend += '\n' + prepend += f' as of {current_date}\n\n' + + def pr_entry(_pr: PullRequest, _depth: int) -> str: + result = ' ' * _depth + display_text = _pr.display_text(fmt=False) + if _pr.number == pr.number: + result += f'* **{display_text} (THIS ONE)**:\n' + else: + result += f'* {display_text}:\n' + result += ' ' * _depth + result += f' `{_pr.base}` ← `{_pr.head}`\n\n' + return result + + base_depth = 0 + for up_pr in pr_up_path: + prepend += pr_entry(up_pr, base_depth) + base_depth += 1 + for (down_pr, depth) in pr_down_tree: + prepend += pr_entry(down_pr, base_depth + depth) prepend += f'{self.END_GIT_MACHETE_GENERATED_COMMENT}\n' return prepend @@ -2721,12 +2785,14 @@ def create_pull_request( title=title, description=description, draft=opt_draft) print(fmt(f'{ok_str}, see `{pr.html_url}`')) + style = self.__get_pr_description_into_style_from_config(spec) # If base branch has NOT originally been found on the remote, - # we can be sure that a longer chain of PRs above the newly-created PR does NOT exist - if base_branch_found_on_remote: + # we can be sure that a longer chain of PRs above the newly-created PR does NOT exist. + # So in the default UP_ONLY mode, we can skip generating the intro completely. + if base_branch_found_on_remote or style == PRDescriptionIntroStyle.FULL: # As the description may include the reference to this PR itself (in case of a chain of >=2 PRs), # let's update the PR description after it's already created (so that we know the current PR's number). - text_to_prepend = self.__generate_text_to_prepend_to_pr_description(code_hosting_client, pr) + text_to_prepend = self.__generate_pr_description_intro(code_hosting_client, pr, style) if text_to_prepend: if description: text_to_prepend += '\n' diff --git a/git_machete/code_hosting.py b/git_machete/code_hosting.py index 0eb4ddbae..6c138a3b8 100644 --- a/git_machete/code_hosting.py +++ b/git_machete/code_hosting.py @@ -89,6 +89,7 @@ class CodeHostingGitConfigKeys(NamedTuple): remote: str annotate_with_urls: str force_description_from_commit_message: str + pr_description_intro_style: str def for_locating_repo_message(self) -> str: return f"`{self.domain}`, `{self.organization}`, `{self.repository}`, `{self.remote}`" diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py index af04dc60d..f86756188 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -249,6 +249,12 @@ Setting this config key to `true` will force `git machete github create-pr` to take PR description from the message body of the first unique commit of the branch, even if `.git/info/description` and/or `.github/pull_request_template.md` is present. + `machete.github.prDescriptionIntroStyle`: + Select the style of the intro prepended to PR description: + * `full` — include both a chain of upstream PRs (typically leading to `main`, `master`, `develop` etc.) and a tree of downstream PRs + * `up-only` — default, include only a chain of upstream PRs + * `none` — prepend no intro to the PR description at all + `machete.gitlab.{domain,remote,namespace,project}`: GitLab self-managed domain @@ -276,6 +282,12 @@ Setting this config key to `true` will force `git machete gitlab create-mr` to take MR description from the message body of the first unique commit of the branch, even if `.git/info/description` and/or `.gitlab/merge_request_templates/Default.md` is present. + `machete.gitlab.mrDescriptionIntroStyle`: + Select the style of the intro prepended to MR description: + * `full` — include both a chain of upstream MRs (typically leading to `main`, `master`, `develop` etc.) and a tree of downstream MRs + * `up-only` — default, include only a chain of upstream MRs + * `none` — prepend no intro to the MR description at all + `machete.overrideForkPoint..to`: Executing `git machete fork-point --override-to[-parent|-inferred|=] []` sets up a fork point override for ``. @@ -656,10 +668,10 @@ Sets the base of the current (or specified) branch's PR to upstream (parent) branch, as seen by git machete (see `git machete show up`). - If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended with a section - listing the entire related chain of PRs. + If after changing the base the PR ends up stacked atop another PR, the PR description posted to GitHub will be prepended + with an intro section listing the entire related chain of PRs. - This header will be updated or removed accordingly with the subsequent runs of `retarget-pr`. + This header will be updated or removed accordingly with the subsequent runs of `retarget-pr`, even if the base branch is already up to date. Options: @@ -715,6 +727,12 @@ Setting this config key to `true` will force `git machete github create-pr` to take PR description from the message body of the first unique commit of the branch, even if `.git/info/description` and/or `.github/pull_request_template.md` is present. + `machete.github.prDescriptionIntroStyle` (`create-pr`, `restack-pr` and `retarget-pr`): + Select the style of the intro prepended to PR description: + * `full` — include both a chain of upstream PRs (typically leading to `main`, `master`, `develop` etc.) and a tree of downstream PRs + * `up-only` — default, include only a chain of upstream PRs + * `none` — prepend no intro to the PR description at all + Environment variables (all subcommands): `GITHUB_TOKEN` GitHub API token. @@ -837,10 +855,10 @@ Sets the target of the current (or specified) branch's MR to upstream (parent) branch, as seen by git machete (see `git machete show up`). - If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended with a section - listing the entire related chain of MRs. + If after changing the target the MR ends up stacked atop another MR, the MR description posted to GitLab will be prepended + with an intro section listing the entire related chain of MRs. - This header will be updated or removed accordingly with the subsequent runs of `retarget-mr`. + This header will be updated or removed accordingly with the subsequent runs of `retarget-mr`, even if the target branch is already up to date. Options: @@ -883,6 +901,12 @@ Setting this config key to `true` will force `git machete gitlab create-mr` to take MR description from the message body of the first unique commit of the branch, even if `.git/info/description` and/or `.gitlab/merge_request_templates/Default.md` is present. + `machete.gitlab.mrDescriptionIntroStyle` (`create-mr`, `restack-mr` and `retarget-mr`): + Select the style of the intro prepended to PR description: + * `full` — include both a chain of upstream PRs (typically leading to `main`, `master`, `develop` etc.) and a tree of downstream PRs + * `up-only` — default, include only a chain of upstream PRs + * `none` — prepend no intro to the PR description at all + Environment variables (all subcommands): `GITLAB_TOKEN` GitLab API token. diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py index 010a705f3..29cc057ec 100644 --- a/git_machete/git_operations.py +++ b/git_machete/git_operations.py @@ -344,6 +344,10 @@ def __ensure_config_loaded(self) -> None: else: raise UnexpectedMacheteException(f"Cannot parse config entry: {config_entry}.") + def get_config_attr(self, key: str, default_value: str) -> str: + value = self.get_config_attr_or_none(key) + return default_value if value is None else value + def get_config_attr_or_none(self, key: str) -> Optional[str]: self.__ensure_config_loaded() assert self.__config_cached is not None diff --git a/git_machete/github.py b/git_machete/github.py index a76f7ae74..081c3adcf 100644 --- a/git_machete/github.py +++ b/git_machete/github.py @@ -179,6 +179,7 @@ def spec(cls) -> CodeHostingSpec: 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 3fc9c2ff0..93f7feb0d 100644 --- a/git_machete/gitlab.py +++ b/git_machete/gitlab.py @@ -134,6 +134,7 @@ def spec(cls) -> CodeHostingSpec: 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', ) ) diff --git a/tests/pytest_full_operands.py b/tests/pytest_full_operands.py index 9a3adac48..6de36721a 100644 --- a/tests/pytest_full_operands.py +++ b/tests/pytest_full_operands.py @@ -23,8 +23,8 @@ def lines_for(arg): return [''] + [' ' + x for x in arg.splitlines()] + [''] return [ 'Comparing values:', - 'left:', *lines_for(left), - 'right:', *lines_for(right) + 'left (typically meaning actual):', *lines_for(left), + 'right (typically meaning expected):', *lines_for(right) ] diff --git a/tests/test_github_create_pr.py b/tests/test_github_create_pr.py index 332e57959..db3ef234c 100644 --- a/tests/test_github_create_pr.py +++ b/tests/test_github_create_pr.py @@ -159,12 +159,13 @@ def test_github_create_pr(self, mocker: MockerFixture) -> None: # Based on PR #3 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #5: - `chore/fields` ➔ `ignore-trailing` * PR #3: - `ignore-trailing` ➔ `hotfix/add-trigger` + `hotfix/add-trigger` ← `ignore-trailing` + + * **PR #5 (THIS ONE)**: + `ignore-trailing` ← `chore/fields` @@ -364,14 +365,16 @@ def test_github_create_pr_for_chain_in_description(self, mocker: MockerFixture) # Based on PR #2 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #3: - `call-ws` ➔ `build-chain` - * PR #2: - `build-chain` ➔ `allow-ownership-link` * PR #1: - `allow-ownership-link` ➔ `develop` + `develop` ← `allow-ownership-link` + + * PR #2: + `allow-ownership-link` ← `build-chain` + + * **PR #3 (THIS ONE)**: + `build-chain` ← `call-ws` ''')[1:] @@ -386,16 +389,19 @@ def test_github_create_pr_for_chain_in_description(self, mocker: MockerFixture) # Based on PR #3 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #4: - `drop-constraint` ➔ `call-ws` - * PR #3: - `call-ws` ➔ `build-chain` - * PR #2: - `build-chain` ➔ `allow-ownership-link` * PR #1: - `allow-ownership-link` ➔ `develop` + `develop` ← `allow-ownership-link` + + * PR #2: + `allow-ownership-link` ← `build-chain` + + * PR #3: + `build-chain` ← `call-ws` + + * **PR #4 (THIS ONE)**: + `call-ws` ← `drop-constraint` @@ -598,12 +604,13 @@ def test_github_create_pr_with_multiple_non_origin_remotes(self, mocker: MockerF # Based on PR #15 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #16: - `feature` ➔ `branch-1` * PR #15: - `branch-1` ➔ `root` + `root` ← `branch-1` + + * **PR #16 (THIS ONE)**: + `branch-1` ← `feature` diff --git a/tests/test_github_retarget_pr.py b/tests/test_github_retarget_pr.py index 5b1a1bdca..57a2455d4 100644 --- a/tests/test_github_retarget_pr.py +++ b/tests/test_github_retarget_pr.py @@ -19,7 +19,6 @@ def github_api_state_for_test_retarget_pr() -> MockGitHubAPIState: mock_pr_json(head='feature_2', base='master', number=25, body=None), mock_pr_json(head='feature_3', base='master', number=30), mock_pr_json(head='feature_4', base='feature', number=35), - # Let's include another PR for `feature_2`, but with a different base branch mock_pr_json(head='feature_4', base='feature', number=40), ) @@ -119,20 +118,20 @@ def test_github_retarget_pr_explicit_branch(self, mocker: MockerFixture) -> None branch_second_commit_msg = "Second commit on branch." ( self.repo_sandbox.new_branch("root") - .commit("First commit on root.") - .new_branch("branch-1") - .commit(branch_first_commit_msg) - .commit(branch_second_commit_msg) - .push() - .new_branch('feature') - .commit('introduce feature') - .push() - .check_out('root') - .new_branch('branch-without-pr') - .commit('branch-without-pr') - .push() - .add_remote('new_origin', 'https://github.com/user/repo.git') - .check_out('root') + .commit("First commit on root.") + .new_branch("branch-1") + .commit(branch_first_commit_msg) + .commit(branch_second_commit_msg) + .push() + .new_branch('feature') + .commit('introduce feature') + .push() + .check_out('root') + .new_branch('branch-without-pr') + .commit('branch-without-pr') + .push() + .add_remote('new_origin', 'https://github.com/user/repo.git') + .check_out('root') ) body: str = \ @@ -201,21 +200,21 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # branch feature present in each remote, no branch tracking data ( self.repo_sandbox.remove_remote() - .new_branch("root") - .add_remote('origin_1', origin_1_remote_path) - .add_remote('origin_2', origin_2_remote_path) - .commit("First commit on root.") - .push(remote='origin_1') - .push(remote='origin_2') - .new_branch("branch-1") - .commit(branch_first_commit_msg) - .commit(branch_second_commit_msg) - .push(remote='origin_1') - .push(remote='origin_2') - .new_branch('feature') - .commit('introduce feature') - .push(remote='origin_1', set_upstream=False) - .push(remote='origin_2', set_upstream=False) + .new_branch("root") + .add_remote('origin_1', origin_1_remote_path) + .add_remote('origin_2', origin_2_remote_path) + .commit("First commit on root.") + .push(remote='origin_1') + .push(remote='origin_2') + .new_branch("branch-1") + .commit(branch_first_commit_msg) + .commit(branch_second_commit_msg) + .push(remote='origin_1') + .push(remote='origin_2') + .new_branch('feature') + .commit('introduce feature') + .push(remote='origin_1', set_upstream=False) + .push(remote='origin_2', set_upstream=False) ) body: str = \ @@ -236,10 +235,10 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # branch feature_1 present in each remote, tracking data present ( self.repo_sandbox.check_out('feature') - .new_branch('feature_1') - .commit('introduce feature 1') - .push(remote='origin_1') - .push(remote='origin_2') + .new_branch('feature_1') + .commit('introduce feature 1') + .push(remote='origin_1') + .push(remote='origin_2') ) body = \ @@ -265,12 +264,13 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on PR #15 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #20: - `feature_1` ➔ `feature` * PR #15: - `feature` ➔ `master` + `master` ← `feature` + + * **PR #20 (THIS ONE)**: + `feature` ← `feature_1` # Summary''')[1:] @@ -278,8 +278,8 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # branch feature_2 is not present in any of the remotes ( self.repo_sandbox.check_out('feature') - .new_branch('feature_2') - .commit('introduce feature 2') + .new_branch('feature_2') + .commit('introduce feature 2') ) body = \ @@ -297,38 +297,26 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # branch feature_2 present in only one remote: origin_1 and there is no tracking data available -> infer the remote ( self.repo_sandbox.check_out('feature_2') - .push(remote='origin_1', set_upstream=False) + .push(remote='origin_1', set_upstream=False) ) + self.repo_sandbox.set_git_config_key("machete.github.prDescriptionIntroStyle", "none") assert_success( ['github', 'retarget-pr'], 'Base branch of PR #25 has been switched to feature\n' - 'Checking for open GitHub PRs (to determine PR chain)... OK\n' 'Description of PR #25 has been updated\n' ) pr25 = github_api_state.get_pull_by_number(25) assert pr25 is not None assert pr25['base']['ref'] == 'feature' - assert pr25['body'] == textwrap.dedent(''' - - - # Based on PR #15 - - ## Full chain of PRs as of 2023-12-31 - - * PR #25: - `feature_2` ➔ `feature` - * PR #15: - `feature` ➔ `master` - - ''')[1:] + assert pr25['body'] == '' # branch feature_3 present in only one remote: origin_1 and has tracking data ( self.repo_sandbox.check_out('feature_2') - .new_branch('feature_3') - .commit('introduce feature 3') - .push(remote='origin_1') + .new_branch('feature_3') + .commit('introduce feature 3') + .push(remote='origin_1') ) body = \ @@ -342,6 +330,7 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt """ rewrite_branch_layout_file(body) + self.repo_sandbox.unset_git_config_key("machete.github.prDescriptionIntroStyle") assert_success( ['github', 'retarget-pr'], 'Base branch of PR #30 has been switched to feature_2\n' @@ -356,14 +345,16 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on PR #25 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #30: - `feature_3` ➔ `feature_2` - * PR #25: - `feature_2` ➔ `feature` * PR #15: - `feature` ➔ `master` + `master` ← `feature` + + * PR #25: + `feature` ← `feature_2` + + * **PR #30 (THIS ONE)**: + `feature_2` ← `feature_3` # Summary''')[1:] @@ -393,12 +384,13 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on PR #15 - ## Full chain of PRs as of 2023-12-31 + ## Chain of upstream PRs as of 2023-12-31 - * PR #30: - `feature_3` ➔ `feature` * PR #15: - `feature` ➔ `master` + `master` ← `feature` + + * **PR #30 (THIS ONE)**: + `feature` ← `feature_3` # Summary''')[1:] @@ -424,6 +416,39 @@ def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixt assert pr30['base']['ref'] == 'root' assert pr30['body'] == '# Summary' + self.repo_sandbox.check_out('feature') + self.repo_sandbox.remove_remote('origin_2') + self.repo_sandbox.set_git_config_key("machete.github.prDescriptionIntroStyle", "full") + + assert_success( + ['github', 'retarget-pr'], + 'Base branch of PR #15 has been switched to branch-1\n' + 'Checking for open GitHub PRs (to determine PR tree)... OK\n' + 'Description of PR #15 has been updated\n' + ) + pr15 = github_api_state.get_pull_by_number(15) + assert pr15 is not None + assert pr15['base']['ref'] == 'branch-1' + assert pr15['body'] == textwrap.dedent(''' + + + ## Chain of upstream PRs & tree of downstream PRs as of 2023-12-31 + + * **PR #15 (THIS ONE)**: + `branch-1` ← `feature` + + * PR #20: + `feature` ← `feature_1` + + * PR #25: + `feature` ← `feature_2` + + * PR #35: + `feature` ← `feature_4` + + + # Summary''')[1:] + @staticmethod def github_api_state_for_test_retarget_pr_root_branch() -> MockGitHubAPIState: return MockGitHubAPIState.with_prs( diff --git a/tests/test_gitlab_create_mr.py b/tests/test_gitlab_create_mr.py index 63e51f0c6..4578ea401 100644 --- a/tests/test_gitlab_create_mr.py +++ b/tests/test_gitlab_create_mr.py @@ -158,12 +158,13 @@ def test_gitlab_create_mr(self, mocker: MockerFixture) -> None: # Based on MR !3 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !5: - `chore/fields` ➔ `ignore-trailing` * MR !3: - `ignore-trailing` ➔ `hotfix/add-trigger` + `hotfix/add-trigger` ← `ignore-trailing` + + * **MR !5 (THIS ONE)**: + `ignore-trailing` ← `chore/fields` @@ -350,47 +351,37 @@ def test_gitlab_create_mr_for_chain_in_description(self, mocker: MockerFixture) """ rewrite_branch_layout_file(body) + self.repo_sandbox.check_out("drop-constraint") + launch_command("gitlab", "create-mr", "--yes") + pr = gitlab_api_state.get_mr_by_number(3) + assert pr is not None + assert pr['description'] == '' # no chain at this moment + + self.repo_sandbox.write_to_file(".gitlab/merge_request_templates/Default.md", "# MR title\n## Summary\n## Test plan\n") + self.repo_sandbox.set_git_config_key("machete.gitlab.mrDescriptionIntroStyle", "full") + self.repo_sandbox.check_out("call-ws") launch_command("gitlab", "create-mr") - pr = gitlab_api_state.get_mr_by_number(3) + pr = gitlab_api_state.get_mr_by_number(4) assert pr is not None assert pr['description'] == textwrap.dedent(''' # Based on MR !2 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs & tree of downstream MRs as of 2023-12-31 - * MR !3: - `call-ws` ➔ `build-chain` - * MR !2: - `build-chain` ➔ `allow-ownership-link` * MR !1: - `allow-ownership-link` ➔ `develop` - - - ''')[1:] + `develop` ← `allow-ownership-link` - self.repo_sandbox.write_to_file(".gitlab/merge_request_templates/Default.md", "# MR title\n## Summary\n## Test plan\n") - self.repo_sandbox.check_out("drop-constraint") - launch_command("gitlab", "create-mr", "--yes") - pr = gitlab_api_state.get_mr_by_number(4) - assert pr is not None - assert pr['description'] == textwrap.dedent(''' - - - # Based on MR !3 + * MR !2: + `allow-ownership-link` ← `build-chain` - ## Full chain of MRs as of 2023-12-31 + * **MR !4 (THIS ONE)**: + `build-chain` ← `call-ws` - * MR !4: - `drop-constraint` ➔ `call-ws` - * MR !3: - `call-ws` ➔ `build-chain` - * MR !2: - `build-chain` ➔ `allow-ownership-link` - * MR !1: - `allow-ownership-link` ➔ `develop` + * MR !3: + `call-ws` ← `drop-constraint` @@ -593,12 +584,13 @@ def test_gitlab_create_mr_with_multiple_non_origin_remotes(self, mocker: MockerF # Based on MR !15 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !16: - `feature` ➔ `branch-1` * MR !15: - `branch-1` ➔ `root` + `root` ← `branch-1` + + * **MR !16 (THIS ONE)**: + `branch-1` ← `feature` diff --git a/tests/test_gitlab_retarget_mr.py b/tests/test_gitlab_retarget_mr.py index 4e8a189ba..c85945c8a 100644 --- a/tests/test_gitlab_retarget_mr.py +++ b/tests/test_gitlab_retarget_mr.py @@ -19,7 +19,6 @@ def gitlab_api_state_for_test_retarget_mr() -> MockGitLabAPIState: mock_mr_json(head='feature_2', base='master', number=25, body=None), mock_mr_json(head='feature_3', base='master', number=30), mock_mr_json(head='feature_4', base='feature', number=35), - # Let's include another MR for `feature_2`, but with a different target branch mock_mr_json(head='feature_4', base='feature', number=40), ) @@ -271,12 +270,13 @@ def test_gitlab_retarget_mr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on MR !15 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !20: - `feature_1` ➔ `feature` * MR !15: - `feature` ➔ `master` + `master` ← `feature` + + * **MR !20 (THIS ONE)**: + `feature` ← `feature_1` # Summary''')[1:] @@ -320,12 +320,13 @@ def test_gitlab_retarget_mr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on MR !15 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !25: - `feature_2` ➔ `feature` * MR !15: - `feature` ➔ `master` + `master` ← `feature` + + * **MR !25 (THIS ONE)**: + `feature` ← `feature_2` ''')[1:] @@ -362,14 +363,16 @@ def test_gitlab_retarget_mr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on MR !25 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !30: - `feature_3` ➔ `feature_2` - * MR !25: - `feature_2` ➔ `feature` * MR !15: - `feature` ➔ `master` + `master` ← `feature` + + * MR !25: + `feature` ← `feature_2` + + * **MR !30 (THIS ONE)**: + `feature_2` ← `feature_3` # Summary''')[1:] @@ -399,12 +402,13 @@ def test_gitlab_retarget_mr_multiple_non_origin_remotes(self, mocker: MockerFixt # Based on MR !15 - ## Full chain of MRs as of 2023-12-31 + ## Chain of upstream MRs as of 2023-12-31 - * MR !30: - `feature_3` ➔ `feature` * MR !15: - `feature` ➔ `master` + `master` ← `feature` + + * **MR !30 (THIS ONE)**: + `feature` ← `feature_3` # Summary''')[1:]