diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 17db617c4..6809beeee 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,10 @@ # Release notes +## New in git-machete 3.26.0 + +- added: better detection of squash merges and rebases, controlled by flag `--squash-merge-detection={none,simple,exact}` (`status` and `traverse`) and git config key `machete.squashMergeDetection` (contributed by @gjulianm) +- deprecated: `--no-detect-squash-merges` flag in `status` and `traverse` — use `--squash-merge-detection=none` instead (contributed by @gjulianm) + ## New in git-machete 3.25.3 - fixed: `-y` option in `git machete traverse` automatically sets `--no-edit-merge` flag, to retain behavior when the `update=merge` qualifier is set (contributed by @gjulianm) diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1 index 204112ef7..bcf66a60c 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" "May 08, 2024" "" "git-machete" +.TH "GIT-MACHETE" "1" "May 24, 2024" "" "git-machete" .SH NAME -git-machete \- git-machete 3.25.3 +git-machete \- git-machete 3.26.0 .sp git machete is a robust tool that \fBsimplifies your git workflows\fP\&. .sp @@ -410,6 +410,28 @@ The override data is stored under \fBmachete.overrideForkPoint..to\fP gi There should be \fBno\fP need for the user to interact with this key directly, \fBgit machete fork\-point\fP with flags should be used instead. .TP +.B \fBmachete.squashMergeDetection\fP: +Controls the algorithm used to detect squash merges. Possible values are: +.INDENT 7.0 +.IP \(bu 2 +\fBnone\fP: Fastest mode, with no squash merge/rebase detection. Only \fIstrict\fP (fast\-forward or 2\-parent) merges are detected. +.IP \(bu 2 +\fBsimple\fP (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. +This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what\(aqs in the downstream branch. +.IP \(bu 2 +\fBexact\fP: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. +This detects squash merges in more cases than \fBsimple\fP mode. +However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. +.UNINDENT +.sp +This has an impact on: +.INDENT 7.0 +.IP \(bu 2 +whether a grey edge is displayed in \fBstatus\fP, +.IP \(bu 2 +whether \fBtraverse\fP suggests to slide out the branch. +.UNINDENT +.TP .B \fBmachete.status.extraSpaceBeforeBranchName\fP: To make it easier to select branch name from the \fBstatus\fP output on certain terminals (like \fI\%Alacritty\fP), you can add an extra space between └─ and \fBbranch name\fP @@ -474,6 +496,9 @@ Example: \fBGIT_MACHETE_REBASE_OPTS=\(dq\-\-keep\-empty \-\-rebase\-merges\(dq g .TP .B \fBGITHUB_TOKEN\fP Used to store GitHub API token. Used by commands such as \fBanno \-\-sync\-github\-prs\fP and \fBgithub\fP\&. +.TP +.B \fBGITLAB_TOKEN\fP +Used to store GitLab API token. Used by commands such as \fBanno \-\-sync\-gitlab\-prs\fP and \fBgitlab\fP\&. .UNINDENT .SH CLEAN .sp @@ -1653,7 +1678,9 @@ since all branches are usually meant to be ultimately merged to one of those. .nf .ft C git machete slide\-out \-\-removed\-from\-remote [\-\-delete] -git machete slide\-out [\-d|\-\-down\-fork\-point=] [\-\-delete] [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] [ [ [ ...]]] +git machete slide\-out [\-d|\-\-down\-fork\-point=] [\-\-delete] + [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] + [ [ [ ...]]] .ft P .fi .UNINDENT @@ -1814,7 +1841,9 @@ Specifies the alternative fork point commit after which the squashed part of his .sp .nf .ft C -git machete s[tatus] [\-\-color=WHEN] [\-l|\-\-list\-commits] [\-L|\-\-list\-commits\-with\-hashes] [\-\-no\-detect\-squash\-merges] +git machete s[tatus] [\-\-color=WHEN] + [\-l|\-\-list\-commits] [\-L|\-\-list\-commits\-with\-hashes] + [\-\-squash\-merge\-detection=MODE] .ft P .fi .UNINDENT @@ -1935,13 +1964,41 @@ Additionally list the commits introduced on each branch. Additionally list the short hashes and messages of commits introduced on each branch. .TP .B \-\-no\-detect\-squash\-merges +\fBDeprecated\fP, use \fB\-\-squash\-merge\-detection=none\fP instead. Only consider \fIstrict\fP (fast\-forward or 2\-parent) merges, rather than rebase/squash merges, when detecting if a branch is merged into its upstream (parent). +.TP +.BI \-\-squash\-merge\-detection\fB= MODE +Specify the mode for detection of rebase/squash merges (grey edges). +\fBMODE\fP can be \fBnone\fP (fastest, no squash merges are detected), \fBsimple\fP (default) or \fBexact\fP (slowest). +See the below paragraph on \fBmachete.squashMergeDetection\fP git config key for more details. .UNINDENT .sp \fBGit config keys:\fP .INDENT 0.0 .TP +.B \fBmachete.squashMergeDetection\fP: +Controls the algorithm used to detect squash merges. Possible values are: +.INDENT 7.0 +.IP \(bu 2 +\fBnone\fP: Fastest mode, with no squash merge/rebase detection. Only \fIstrict\fP (fast\-forward or 2\-parent) merges are detected. +.IP \(bu 2 +\fBsimple\fP (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. +This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what\(aqs in the downstream branch. +.IP \(bu 2 +\fBexact\fP: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. +This detects squash merges in more cases than \fBsimple\fP mode. +However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. +.UNINDENT +.sp +This has an impact on: +.INDENT 7.0 +.IP \(bu 2 +whether a grey edge is displayed in \fBstatus\fP, +.IP \(bu 2 +whether \fBtraverse\fP suggests to slide out the branch. +.UNINDENT +.TP .B \fBmachete.status.extraSpaceBeforeBranchName\fP To make it easier to select branch name from the \fBstatus\fP output on certain terminals (like \fI\%Alacritty\fP), you can add an extra space between └─ and \fBbranch name\fP @@ -1956,9 +2013,9 @@ by setting \fBgit config machete.status.extraSpaceBeforeBranchName true\fP\&. .nf .ft C git machete t[raverse] [\-F|\-\-fetch] [\-l|\-\-list\-commits] [\-M|\-\-merge] - [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] [\-\-no\-detect\-squash\-merges] - [\-\-[no\-]push] [\-\-[no\-]push\-untracked] - [\-\-return\-to=WHERE] [\-\-start\-from=WHERE] [\-w|\-\-whole] [\-W] [\-y|\-\-yes] + [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] [\-\-[no\-]push] [\-\-[no\-]push\-untracked] + [\-\-return\-to=WHERE] [\-\-start\-from=WHERE] [\-\-squash\-merge\-detection=MODE] + [\-w|\-\-whole] [\-W] [\-y|\-\-yes] .ft P .fi .UNINDENT @@ -2023,7 +2080,7 @@ It can also be customized using options: \fB\-\-[no\-]push\fP or \fB\-\-[no\-]pu If the traverse flow is stopped (typically due to merge/rebase conflicts), just run \fBgit machete traverse\fP after the merge/rebase is finished. It will pick up the walk from the current branch. Unlike with \fBgit rebase\fP or \fBgit cherry\-pick\fP, there is no special \fB\-\-continue\fP flag, as \fBtraverse\fP is stateless. -\fBtraverse\fP doesn\(aqt keep a state of its own like \fBgit rebase\fP does in \fB\&.git/rebase\-apply/\fP\&. +\fBtraverse\fP does \fBnot\fP keep a state of its own like \fBgit rebase\fP does in \fB\&.git/rebase\-apply/\fP\&. .sp The rebase, push and slide\-out behaviors of \fBtraverse\fP can also be customized for each branch separately using \fIbranch qualifiers\fP\&. There are \fBpush=no\fP, \fBrebase=no\fP and \fBslide\-out=no\fP qualifiers that can be used to opt out of default behavior (rebasing, pushing and sliding the branch out). @@ -2065,6 +2122,7 @@ Update by merge rather than by rebase. If updating by rebase, equivalent to \fB\-\-no\-interactive\-rebase\fP\&. If updating by merge, equivalent to \fB\-\-no\-edit\-merge\fP\&. .TP .B \-\-no\-detect\-squash\-merges +\fBDeprecated\fP, use \fB\-\-squash\-merge\-detection=none\fP instead. Only consider \fIstrict\fP (fast\-forward or 2\-parent) merges, rather than rebase/squash merges, when detecting if a branch is merged into its upstream (parent). .TP @@ -2097,6 +2155,11 @@ WHERE can be \fBhere\fP (the current branch at the moment when traversal starts) \fBstay\fP (the default \-\-\- just stay wherever the traversal stops). Note: when user quits by \fBq\fP/\fByq\fP or when traversal is stopped because one of git actions fails, the behavior is always \fBstay\fP\&. .TP +.BI \-\-squash\-merge\-detection\fB= MODE +Specifies the mode for detection of rebase/squash merges (grey edges). +\fBMODE\fP can be \fBnone\fP (fastest, no squash merges are detected), \fBsimple\fP (default) or \fBexact\fP (slowest). +See the below paragraph on \fBmachete.squashMergeDetection\fP git config key for more details. +.TP .BI \-\-start\-from\fB= WHERE Specifies the branch to start the traversal from; WHERE can be \fBhere\fP (the default \-\-\- current branch, must be managed by git machete), \fBroot\fP (root branch of the current branch, @@ -2125,6 +2188,28 @@ Example: \fBGIT_MACHETE_REBASE_OPTS=\(dq\-\-keep\-empty \-\-rebase\-merges\(dq g \fBGit config keys:\fP .INDENT 0.0 .TP +.B \fBmachete.squashMergeDetection\fP: +Controls the algorithm used to detect squash merges. Possible values are: +.INDENT 7.0 +.IP \(bu 2 +\fBnone\fP: Fastest mode, with no squash merge/rebase detection. Only \fIstrict\fP (fast\-forward or 2\-parent) merges are detected. +.IP \(bu 2 +\fBsimple\fP (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. +This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what\(aqs in the downstream branch. +.IP \(bu 2 +\fBexact\fP: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. +This detects squash merges in more cases than \fBsimple\fP mode. +However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. +.UNINDENT +.sp +This has an impact on: +.INDENT 7.0 +.IP \(bu 2 +whether a grey edge is displayed in \fBstatus\fP, +.IP \(bu 2 +whether \fBtraverse\fP suggests to slide out the branch. +.UNINDENT +.TP .B \fBmachete.traverse.push\fP To change the behavior of \fBgit machete traverse\fP command so that it doesn\(aqt push branches by default, you need to set config key \fBgit config machete.traverse.push false\fP\&. @@ -2139,7 +2224,7 @@ Configuration key value can be overridden by the presence of the \fB\-\-push\fP .sp .nf .ft C -git machete update [\-f|\-\-fork\-point=] [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] +git machete update [\-f|\-\-fork\-point=] [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] .ft P .fi .UNINDENT diff --git a/docs/source/cli/config.rst b/docs/source/cli/config.rst index 3e546d86f..6c3e93ff7 100644 --- a/docs/source/cli/config.rst +++ b/docs/source/cli/config.rst @@ -36,6 +36,9 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git There should be **no** need for the user to interact with this key directly, ``git machete fork-point`` with flags should be used instead. +``machete.squashMergeDetection``: + .. include:: git-config-keys/squashMergeDetection.rst + ``machete.status.extraSpaceBeforeBranchName``: .. include:: git-config-keys/status_extraSpaceBeforeBranchName.rst @@ -49,7 +52,6 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git If you want the worktree to have its own branch layout file (located under ``.git/worktrees/.../machete``), set ``git config machete.worktree.useTopLevelMacheteFile false``. - **Environment variables:** ``GIT_MACHETE_EDITOR`` @@ -62,3 +64,6 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git ``GITHUB_TOKEN`` Used to store GitHub API token. Used by commands such as ``anno --sync-github-prs`` and ``github``. + +``GITLAB_TOKEN`` + Used to store GitLab API token. Used by commands such as ``anno --sync-gitlab-prs`` and ``gitlab``. diff --git a/docs/source/cli/slide-out.rst b/docs/source/cli/slide-out.rst index 3a570168c..9e09a5539 100644 --- a/docs/source/cli/slide-out.rst +++ b/docs/source/cli/slide-out.rst @@ -7,7 +7,9 @@ slide-out .. code-block:: shell git machete slide-out --removed-from-remote [--delete] - git machete slide-out [-d|--down-fork-point=] [--delete] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] [ [ [ ...]]] + git machete slide-out [-d|--down-fork-point=] [--delete] + [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] + [ [ [ ...]]] Removes the given branch (or multiple branches) from the branch layout. If no branch has been specified, current branch is slid out. diff --git a/docs/source/cli/status.rst b/docs/source/cli/status.rst index d7c992122..8149aab0e 100644 --- a/docs/source/cli/status.rst +++ b/docs/source/cli/status.rst @@ -18,7 +18,9 @@ status .. code-block:: shell - git machete s[tatus] [--color=WHEN] [-l|--list-commits] [-L|--list-commits-with-hashes] [--no-detect-squash-merges] + git machete s[tatus] [--color=WHEN] + [-l|--list-commits] [-L|--list-commits-with-hashes] + [--squash-merge-detection=MODE] Displays a tree-shaped status of the branches listed in the branch layout file. @@ -83,11 +85,19 @@ When colors are disabled, relation between branches is represented in the follow -L, --list-commits-with-hashes Additionally list the short hashes and messages of commits introduced on each branch. ---no-detect-squash-merges Only consider *strict* (fast-forward or 2-parent) merges, rather than rebase/squash merges, +--no-detect-squash-merges **Deprecated**, use ``--squash-merge-detection=none`` instead. + Only consider *strict* (fast-forward or 2-parent) merges, rather than rebase/squash merges, when detecting if a branch is merged into its upstream (parent). +--squash-merge-detection=MODE Specify the mode for detection of rebase/squash merges (grey edges). + ``MODE`` can be ``none`` (fastest, no squash merges are detected), ``simple`` (default) or ``exact`` (slowest). + See the below paragraph on ``machete.squashMergeDetection`` git config key for more details. + **Git config keys:** +``machete.squashMergeDetection``: + .. include:: git-config-keys/squashMergeDetection.rst + ``machete.status.extraSpaceBeforeBranchName`` .. include:: git-config-keys/status_extraSpaceBeforeBranchName.rst :end-line: 3 diff --git a/docs/source/cli/traverse.rst b/docs/source/cli/traverse.rst index 92c538bfc..3a66a967e 100644 --- a/docs/source/cli/traverse.rst +++ b/docs/source/cli/traverse.rst @@ -19,9 +19,9 @@ traverse .. code-block:: shell git machete t[raverse] [-F|--fetch] [-l|--list-commits] [-M|--merge] - [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges] - [--[no-]push] [--[no-]push-untracked] - [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes] + [-n|--no-edit-merge|--no-interactive-rebase] [--[no-]push] [--[no-]push-untracked] + [--return-to=WHERE] [--start-from=WHERE] [--squash-merge-detection=MODE] + [-w|--whole] [-W] [-y|--yes] Traverses the branches in the order as they occur in branch layout file. By default, ``traverse`` starts from the current branch. @@ -63,7 +63,7 @@ It can also be customized using options: ``--[no-]push`` or ``--[no-]push-untrac If the traverse flow is stopped (typically due to merge/rebase conflicts), just run ``git machete traverse`` after the merge/rebase is finished. It will pick up the walk from the current branch. Unlike with ``git rebase`` or ``git cherry-pick``, there is no special ``--continue`` flag, as ``traverse`` is stateless. -``traverse`` doesn't keep a state of its own like ``git rebase`` does in ``.git/rebase-apply/``. +``traverse`` does **not** keep a state of its own like ``git rebase`` does in ``.git/rebase-apply/``. The rebase, push and slide-out behaviors of ``traverse`` can also be customized for each branch separately using *branch qualifiers*. There are ``push=no``, ``rebase=no`` and ``slide-out=no`` qualifiers that can be used to opt out of default behavior (rebasing, pushing and sliding the branch out). @@ -86,50 +86,55 @@ when the current user is NOT the author of the PR/MR associated with that branch **Options:** --F, --fetch Fetch the remotes of all managed branches at the beginning of traversal (no ``git pull`` involved, only ``git fetch``). +-F, --fetch Fetch the remotes of all managed branches at the beginning of traversal (no ``git pull`` involved, only ``git fetch``). --l, --list-commits When printing the status, additionally list the messages of commits introduced on each branch. +-l, --list-commits When printing the status, additionally list the messages of commits introduced on each branch. --M, --merge Update by merge rather than by rebase. +-M, --merge Update by merge rather than by rebase. --n If updating by rebase, equivalent to ``--no-interactive-rebase``. If updating by merge, equivalent to ``--no-edit-merge``. +-n If updating by rebase, equivalent to ``--no-interactive-rebase``. If updating by merge, equivalent to ``--no-edit-merge``. ---no-detect-squash-merges Only consider *strict* (fast-forward or 2-parent) merges, rather than rebase/squash merges, - when detecting if a branch is merged into its upstream (parent). +--no-detect-squash-merges **Deprecated**, use ``--squash-merge-detection=none`` instead. + Only consider *strict* (fast-forward or 2-parent) merges, rather than rebase/squash merges, + when detecting if a branch is merged into its upstream (parent). ---no-edit-merge If updating by merge, skip opening the editor for merge commit message while doing ``git merge`` - (that is, pass ``--no-edit`` flag to the underlying ``git merge``). Not allowed if updating by rebase. +--no-edit-merge If updating by merge, skip opening the editor for merge commit message while doing ``git merge`` + (that is, pass ``--no-edit`` flag to the underlying ``git merge``). Not allowed if updating by rebase. ---no-interactive-rebase If updating by rebase, run ``git rebase`` in non-interactive mode (without ``-i/--interactive`` flag). - Not allowed if updating by merge. +--no-interactive-rebase If updating by rebase, run ``git rebase`` in non-interactive mode (without ``-i/--interactive`` flag). + Not allowed if updating by merge. ---no-push Do not push any (neither tracked nor untracked) branches to remote, re-enable via ``--push``. +--no-push Do not push any (neither tracked nor untracked) branches to remote, re-enable via ``--push``. ---no-push-untracked Do not push untracked branches to remote, re-enable via ``--push-untracked``. +--no-push-untracked Do not push untracked branches to remote, re-enable via ``--push-untracked``. ---push Push all (both tracked and untracked) branches to remote --- default behavior. Default behavior can be changed - by setting git configuration key ``git config machete.traverse.push false``. - Configuration key value can be overridden by the presence of the flag. +--push Push all (both tracked and untracked) branches to remote --- default behavior. Default behavior can be changed + by setting git configuration key ``git config machete.traverse.push false``. + Configuration key value can be overridden by the presence of the flag. ---push-untracked Push untracked branches to remote. +--push-untracked Push untracked branches to remote. ---return-to=WHERE Specifies the branch to return after traversal is successfully completed; - WHERE can be ``here`` (the current branch at the moment when traversal starts), ``nearest-remaining`` - (nearest remaining branch in case the ``here`` branch has been slid out by the traversal) or - ``stay`` (the default --- just stay wherever the traversal stops). Note: when user quits by ``q``/``yq`` - or when traversal is stopped because one of git actions fails, the behavior is always ``stay``. +--return-to=WHERE Specifies the branch to return after traversal is successfully completed; + WHERE can be ``here`` (the current branch at the moment when traversal starts), ``nearest-remaining`` + (nearest remaining branch in case the ``here`` branch has been slid out by the traversal) or + ``stay`` (the default --- just stay wherever the traversal stops). Note: when user quits by ``q``/``yq`` + or when traversal is stopped because one of git actions fails, the behavior is always ``stay``. ---start-from=WHERE Specifies the branch to start the traversal from; WHERE can be ``here`` - (the default --- current branch, must be managed by git machete), ``root`` (root branch of the current branch, - as in ``git machete show root``) or ``first-root`` (first listed managed branch). +--squash-merge-detection=MODE Specifies the mode for detection of rebase/squash merges (grey edges). + ``MODE`` can be ``none`` (fastest, no squash merges are detected), ``simple`` (default) or ``exact`` (slowest). + See the below paragraph on ``machete.squashMergeDetection`` git config key for more details. --w, --whole Equivalent to ``-n --start-from=first-root --return-to=nearest-remaining``; - useful for quickly traversing & syncing all branches (rather than doing more fine-grained operations on the - local section of the branch tree). +--start-from=WHERE Specifies the branch to start the traversal from; WHERE can be ``here`` + (the default --- current branch, must be managed by git machete), ``root`` (root branch of the current branch, + as in ``git machete show root``) or ``first-root`` (first listed managed branch). --W Equivalent to ``--fetch --whole``; useful for even more automated traversal of all branches. +-w, --whole Equivalent to ``-n --start-from=first-root --return-to=nearest-remaining``; + useful for quickly traversing & syncing all branches (rather than doing more fine-grained operations on the + local section of the branch tree). --y, --yes Don't ask for any interactive input, including confirmation of rebase/push/pull. Implies ``-n``. +-W Equivalent to ``--fetch --whole``; useful for even more automated traversal of all branches. + +-y, --yes Don't ask for any interactive input, including confirmation of rebase/push/pull. Implies ``-n``. **Environment variables:** @@ -139,5 +144,8 @@ when the current user is NOT the author of the PR/MR associated with that branch **Git config keys:** +``machete.squashMergeDetection``: + .. include:: git-config-keys/squashMergeDetection.rst + ``machete.traverse.push`` .. include:: git-config-keys/traverse_push.rst diff --git a/docs/source/cli/update.rst b/docs/source/cli/update.rst index f30bca4cf..874965936 100644 --- a/docs/source/cli/update.rst +++ b/docs/source/cli/update.rst @@ -6,7 +6,7 @@ update .. code-block:: shell - git machete update [-f|--fork-point=] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] + git machete update [-f|--fork-point=] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] Synchronizes the current branch with its upstream (parent) branch either by rebase (default) or by merge (if ``--merge`` option passed). diff --git a/docs/source/git-config-keys/squashMergeDetection.rst b/docs/source/git-config-keys/squashMergeDetection.rst new file mode 100644 index 000000000..7f06e2e39 --- /dev/null +++ b/docs/source/git-config-keys/squashMergeDetection.rst @@ -0,0 +1,13 @@ +Controls the algorithm used to detect squash merges. Possible values are: + +* ``none``: Fastest mode, with no squash merge/rebase detection. Only *strict* (fast-forward or 2-parent) merges are detected. +* ``simple`` (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. + This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what's in the downstream branch. +* ``exact``: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. + This detects squash merges in more cases than ``simple`` mode. + However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. + +This has an impact on: + +* whether a grey edge is displayed in ``status``, +* whether ``traverse`` suggests to slide out the branch. diff --git a/git_machete/__init__.py b/git_machete/__init__.py index 6889cb44a..c1292df9b 100644 --- a/git_machete/__init__.py +++ b/git_machete/__init__.py @@ -1 +1 @@ -__version__ = '3.25.3' +__version__ = '3.26.0' diff --git a/git_machete/cli.py b/git_machete/cli.py index 7de705797..e652be76c 100644 --- a/git_machete/cli.py +++ b/git_machete/cli.py @@ -10,6 +10,7 @@ import git_machete.options from git_machete import __version__, git_config_keys, utils +from git_machete.constants import SquashMergeDetection from git_machete.github import GitHubClient from git_machete.gitlab import GitLabClient @@ -70,7 +71,7 @@ def get_help_description(display_help_topics: bool, command: Optional[str] = Non usage_str += underline(hdr) + '\n\n' for cm in cmds: alias = f", {alias_by_command[cm]}" if cm in alias_by_command else "" - usage_str += f' {bold(cm + alias) : <{18 if utils.ascii_only else 27}}{short_docs[cm]}' + usage_str += f' {bold(cm + alias): <{18 if utils.ascii_only else 27}}{short_docs[cm]}' usage_str += '\n' usage_str += '\n' usage_str += fmt(textwrap.dedent(""" @@ -364,6 +365,7 @@ def add_code_hosting_parser(command: str, subcommand_suffix: str, include_sync: status_parser.add_argument('-l', '--list-commits', action='store_true') status_parser.add_argument('-L', '--list-commits-with-hashes', action='store_true') status_parser.add_argument('--no-detect-squash-merges', action='store_true') + status_parser.add_argument('--squash-merge-detection') traverse_parser = subparsers.add_parser( 'traverse', @@ -379,6 +381,7 @@ def add_code_hosting_parser(command: str, subcommand_suffix: str, include_sync: traverse_parser.add_argument('--no-edit-merge', action='store_true') traverse_parser.add_argument('--no-interactive-rebase', action='store_true') traverse_parser.add_argument('--no-detect-squash-merges', action='store_true') + traverse_parser.add_argument('--squash-merge-detection') traverse_parser.add_argument('--push', action='store_true') traverse_parser.add_argument('--no-push', action='store_true') traverse_parser.add_argument('--push-untracked', action='store_true') @@ -455,7 +458,11 @@ def update_cli_options_using_parsed_args( elif opt == "n": cli_opts.opt_n = True elif opt == "no_detect_squash_merges": - cli_opts.opt_no_detect_squash_merges = True + warn("`--no-detect-squash-merges` is deprecated, use `--squash-merge-detection=none` instead", end="\n\n") + cli_opts.opt_squash_merge_detection_string = "none" + elif opt == "squash_merge_detection" and arg is not None: # if no arg is passed, argparse will fail anyway + cli_opts.opt_squash_merge_detection_string = arg + cli_opts.opt_squash_merge_detection_origin = "`--squash-merge-detection` flag" elif opt == "no_edit_merge": cli_opts.opt_no_edit_merge = True elif opt == "no_interactive_rebase": @@ -529,6 +536,13 @@ def update_cli_options_using_config_keys( else: cli_opts.opt_push_tracked, cli_opts.opt_push_untracked = False, False + squash_merge_detection = git.get_config_attr_or_none(key=git_config_keys.SQUASH_MERGE_DETECTION) + if squash_merge_detection is not None: + # Let's defer the validation until the value is actually used in `status` or `traverse`. + # Otherwise, if an invalid value ends up in git config, `git machete help` will instantly fail. + cli_opts.opt_squash_merge_detection_string = squash_merge_detection + cli_opts.opt_squash_merge_detection_origin = f"`{git_config_keys.SQUASH_MERGE_DETECTION}` git config key" + def set_utils_global_variables(parsed_args: argparse.Namespace) -> None: args = vars(parsed_args) @@ -861,34 +875,38 @@ def strip_remote_name(remote_branch: RemoteBranchShortName) -> LocalBranchShortN ) machete_client.squash(current_branch=current_branch, opt_fork_point=squash_fork_point) elif cmd in {"status", alias_by_command["status"]}: + opt_squash_merge_detection = SquashMergeDetection.from_string( + cli_opts.opt_squash_merge_detection_string, cli_opts.opt_squash_merge_detection_origin) + machete_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out) machete_client.expect_at_least_one_managed_branch() machete_client.status( warn_when_branch_in_sync_but_fork_point_off=True, opt_list_commits=cli_opts.opt_list_commits, opt_list_commits_with_hashes=cli_opts.opt_list_commits_with_hashes, - opt_no_detect_squash_merges=cli_opts.opt_no_detect_squash_merges) + opt_squash_merge_detection=opt_squash_merge_detection) elif cmd in {"traverse", alias_by_command["traverse"]}: + if cli_opts.opt_return_to not in {"here", "nearest-remaining", "stay"}: + raise MacheteException(f"Invalid value for `--return-to` flag: `{cli_opts.opt_return_to}`. " + "Valid values are here, nearest-remaining, stay") if cli_opts.opt_start_from not in {"here", "root", "first-root"}: - raise MacheteException( - "Invalid argument for `--start-from`. " - "Valid arguments: `here|root|first-root`.") - if cli_opts.opt_return_to not in ("here", "nearest-remaining", "stay"): - raise MacheteException( - "Invalid argument for `--return-to`. " - "Valid arguments: `here|nearest-remaining|stay`.") + raise MacheteException(f"Invalid value for `--start-from` flag: `{cli_opts.opt_start_from}`. " + "Valid values are here, root, first-root") + opt_squash_merge_detection = SquashMergeDetection.from_string( + cli_opts.opt_squash_merge_detection_string, cli_opts.opt_squash_merge_detection_origin) + machete_client.read_branch_layout_file(perform_interactive_slide_out=should_perform_interactive_slide_out) git.expect_no_operation_in_progress() machete_client.traverse( opt_fetch=cli_opts.opt_fetch, opt_list_commits=cli_opts.opt_list_commits, opt_merge=cli_opts.opt_merge, - opt_no_detect_squash_merges=cli_opts.opt_no_detect_squash_merges, opt_no_edit_merge=cli_opts.opt_no_edit_merge, opt_no_interactive_rebase=cli_opts.opt_no_interactive_rebase, opt_push_tracked=cli_opts.opt_push_tracked, opt_push_untracked=cli_opts.opt_push_untracked, opt_return_to=cli_opts.opt_return_to, + opt_squash_merge_detection=opt_squash_merge_detection, opt_start_from=cli_opts.opt_start_from, opt_yes=cli_opts.opt_yes) elif cmd == "update": diff --git a/git_machete/client.py b/git_machete/client.py index 7e8782d28..742a3269f 100644 --- a/git_machete/client.py +++ b/git_machete/client.py @@ -17,7 +17,7 @@ is_matching_remote_url) from .constants import (DISCOVER_DEFAULT_FRESH_BRANCH_COUNT, PICK_FIRST_ROOT, PICK_LAST_ROOT, GitFormatPatterns, - SyncToRemoteStatuses) + SquashMergeDetection, SyncToRemoteStatuses) from .exceptions import (InteractionStopped, MacheteException, UnexpectedMacheteException) from .git_operations import (HEAD, AnyBranchName, AnyRevision, BranchPair, @@ -463,7 +463,7 @@ def get_root_of(branch: LocalBranchShortName) -> LocalBranchShortName: if self.is_merged_to( branch=branch, upstream=upstream, - opt_no_detect_squash_merges=False + opt_squash_merge_detection=SquashMergeDetection.NONE ): debug(f"inferred upstream of {branch} is {upstream}, but " f"{branch} is merged to {upstream}; skipping {branch} from discovered tree") @@ -489,7 +489,7 @@ def get_root_of(branch: LocalBranchShortName) -> LocalBranchShortName: warn_when_branch_in_sync_but_fork_point_off=False, opt_list_commits=opt_list_commits, opt_list_commits_with_hashes=False, - opt_no_detect_squash_merges=False) + opt_squash_merge_detection=SquashMergeDetection.NONE) print("") do_backup = os.path.isfile(self.__branch_layout_file_path) and io.open(self.__branch_layout_file_path).read().strip() backup_msg = ( @@ -611,7 +611,7 @@ def advance(self, *, branch: LocalBranchShortName, opt_yes: bool) -> None: def connected_with_green_edge(bd: LocalBranchShortName) -> bool: return bool( - not self.__is_merged_to_upstream(bd, opt_no_detect_squash_merges=False) and + not self.__is_merged_to_upstream(bd, opt_squash_merge_detection=SquashMergeDetection.NONE) and self.__git.is_ancestor_or_equal(branch.full_name(), bd.full_name()) and (self.__get_overridden_fork_point(bd) or self.__git.get_commit_hash_by_revision(branch) == self.fork_point(bd, use_overrides=False))) @@ -685,12 +685,12 @@ def traverse( opt_fetch: bool, opt_list_commits: bool, opt_merge: bool, - opt_no_detect_squash_merges: bool, opt_no_edit_merge: bool, opt_no_interactive_rebase: bool, opt_push_tracked: bool, opt_push_untracked: bool, opt_return_to: str, + opt_squash_merge_detection: SquashMergeDetection, opt_start_from: str, opt_yes: bool ) -> None: @@ -730,7 +730,7 @@ def traverse( upstream = self.__up_branch.get(branch) needs_slide_out: bool = self.__is_merged_to_upstream( - branch, opt_no_detect_squash_merges=opt_no_detect_squash_merges) + branch, opt_squash_merge_detection=opt_squash_merge_detection) if needs_slide_out and branch in self.annotations: needs_slide_out = self.annotations[branch].qualifiers.slide_out s, remote = self.__git.get_combined_remote_sync_status(branch) @@ -785,7 +785,7 @@ def traverse( warn_when_branch_in_sync_but_fork_point_off=True, opt_list_commits=opt_list_commits, opt_list_commits_with_hashes=False, - opt_no_detect_squash_merges=opt_no_detect_squash_merges) + opt_squash_merge_detection=opt_squash_merge_detection) self.__print_new_line(True) if needs_slide_out: any_action_suggested = True @@ -944,7 +944,7 @@ def traverse( warn_when_branch_in_sync_but_fork_point_off=True, opt_list_commits=opt_list_commits, opt_list_commits_with_hashes=False, - opt_no_detect_squash_merges=opt_no_detect_squash_merges) + opt_squash_merge_detection=opt_squash_merge_detection) print("") if current_branch == self.managed_branches[-1]: msg: str = f"Reached branch {bold(current_branch)} which has no successor" @@ -969,7 +969,7 @@ def status( warn_when_branch_in_sync_but_fork_point_off: bool, opt_list_commits: bool, opt_list_commits_with_hashes: bool, - opt_no_detect_squash_merges: bool + opt_squash_merge_detection: SquashMergeDetection ) -> None: next_sibling_of_ancestor_by_branch: OrderedDict[LocalBranchShortName, List[Optional[LocalBranchShortName]]] = OrderedDict() @@ -1009,7 +1009,7 @@ def fork_point_hash(branch_: LocalBranchShortName) -> Optional[FullCommitHash]: if self.is_merged_to( branch=branch, upstream=parent_branch, - opt_no_detect_squash_merges=opt_no_detect_squash_merges): + opt_squash_merge_detection=opt_squash_merge_detection): sync_to_parent_status[branch] = SyncToParentStatus.MergedToParent elif not self.__git.is_ancestor_or_equal(parent_branch.full_name(), branch.full_name()): sync_to_parent_status[branch] = SyncToParentStatus.OutOfSync @@ -1065,8 +1065,8 @@ def print_line_prefix(branch_: LocalBranchShortName, suffix: str) -> None: right_arrow = colored(utils.get_right_arrow(), AnsiEscapeCodes.RED) fork_point_str = colored("fork point ???", AnsiEscapeCodes.RED) fp_suffix: str = f' {right_arrow} {fork_point_str} ' + \ - ("this commit" if opt_list_commits_with_hashes else f"commit {commit.short_hash}") + \ - f' seems to be a part of the unique history of {fp_branches_formatted}' + ("this commit" if opt_list_commits_with_hashes else f"commit {commit.short_hash}") + \ + f' seems to be a part of the unique history of {fp_branches_formatted}' else: fp_suffix = '' print_line_prefix(branch, utils.get_vertical_bar()) @@ -1159,7 +1159,7 @@ def print_line_prefix(branch_: LocalBranchShortName, suffix: str) -> None: else: affected_branches = ", ".join(map(bold, branches_in_sync_but_fork_point_off)) first_part = f"yellow edges indicate that fork points for {affected_branches} are probably incorrectly inferred,\n" \ - "or that some extra branch should be added between each of these branches and its parent" + "or that some extra branch should be added between each of these branches and its parent" if not opt_list_commits: second_part = "Run `git machete status --list-commits` or " \ @@ -1527,11 +1527,11 @@ def get_slidable_after(self, branch: LocalBranchShortName) -> List[LocalBranchSh return [] def __is_merged_to_upstream( - self, branch: LocalBranchShortName, *, opt_no_detect_squash_merges: bool) -> bool: + self, branch: LocalBranchShortName, *, opt_squash_merge_detection: SquashMergeDetection) -> bool: upstream = self.__up_branch.get(branch) if not upstream: return False - return self.is_merged_to(branch, upstream, opt_no_detect_squash_merges=opt_no_detect_squash_merges) + return self.is_merged_to(branch, upstream, opt_squash_merge_detection=opt_squash_merge_detection) def __run_post_slide_out_hook(self, new_upstream: LocalBranchShortName, slid_out_branch: LocalBranchShortName, new_downstreams: List[LocalBranchShortName]) -> None: @@ -1866,11 +1866,13 @@ def __pick_remote( ) -> None: rems = self.__git.get_remotes() print("\n".join(f"[{index + 1}] {rem}" for index, rem in enumerate(rems))) - msg = f"Select number 1..{len(rems)} to specify the destination remote " \ - "repository, or 'n' to skip this branch, or " \ - "'q' to quit the traverse: " if is_called_from_traverse \ - else f"Select number 1..{len(rems)} to specify the destination remote " \ - "repository, or 'q' to quit the operation: " + if is_called_from_traverse: + msg = f"Select number 1..{len(rems)} to specify the destination remote " \ + "repository, or 'n' to skip this branch, or " \ + "'q' to quit the traverse: " + else: + msg = f"Select number 1..{len(rems)} to specify the destination remote " \ + "repository, or 'q' to quit the operation: " ans = input(msg).lower() if ans in ('q', 'quit'): @@ -2012,7 +2014,12 @@ def __handle_untracked_branch( elif ans in ('q', 'quit'): raise InteractionStopped - def is_merged_to(self, branch: LocalBranchShortName, upstream: AnyBranchName, *, opt_no_detect_squash_merges: bool) -> bool: + def is_merged_to( + self, + branch: LocalBranchShortName, + upstream: AnyBranchName, + opt_squash_merge_detection: SquashMergeDetection + ) -> bool: if self.__git.is_ancestor_or_equal(branch.full_name(), upstream.full_name()): # If branch is ancestor of or equal to the upstream, we need to distinguish between the # case of branch being "recently" created from the upstream and the case of @@ -2021,13 +2028,19 @@ def is_merged_to(self, branch: LocalBranchShortName, upstream: AnyBranchName, *, # (reflog stripped of trivial events like branch creation, reset etc.) # is non-empty. return bool(self.filtered_reflog(branch)) - elif opt_no_detect_squash_merges: + elif opt_squash_merge_detection == SquashMergeDetection.NONE: return False - else: + elif opt_squash_merge_detection == SquashMergeDetection.SIMPLE: # In the default mode. # If a commit with an identical tree state to branch is reachable from upstream, # then branch may have been squashed or rebase-merged into upstream. return self.__git.is_equivalent_tree_reachable(branch, upstream) + elif opt_squash_merge_detection == SquashMergeDetection.EXACT: + # Let's try another way, a little more complex but takes into account the possibility + # that there were other commits between the common ancestor of the two branches and the squashed merge. + return self.__git.is_equivalent_tree_reachable(branch, upstream) or self.__git.is_equivalent_patch_reachable(branch, upstream) + else: # pragma: no cover + raise UnexpectedMacheteException(f"Invalid squash merged detection mode: {opt_squash_merge_detection}.") @staticmethod def ask_if( @@ -2321,7 +2334,7 @@ def restack_pull_request(self, spec: CodeHostingSpec) -> None: warn_when_branch_in_sync_but_fork_point_off=True, opt_list_commits=False, opt_list_commits_with_hashes=False, - opt_no_detect_squash_merges=False) + opt_squash_merge_detection=SquashMergeDetection.NONE) self.__print_new_line(False) if converted_to_draft: @@ -2855,7 +2868,7 @@ def __sync_before_creating_pr(self, spec: CodeHostingSpec, *, opt_onto: Optional warn_when_branch_in_sync_but_fork_point_off=True, opt_list_commits=False, opt_list_commits_with_hashes=False, - opt_no_detect_squash_merges=False) + opt_squash_merge_detection=SquashMergeDetection.NONE) self.__print_new_line(False) else: diff --git a/git_machete/constants.py b/git_machete/constants.py index 5e20ec2b3..54c615686 100644 --- a/git_machete/constants.py +++ b/git_machete/constants.py @@ -1,7 +1,11 @@ from enum import Enum, IntEnum +from typing import Optional + +from git_machete.exceptions import MacheteException -MAX_COUNT_FOR_INITIAL_LOG = 10 DISCOVER_DEFAULT_FRESH_BRANCH_COUNT = 10 +MAX_COMMITS_FOR_SQUASH_MERGE_DETECTION = 1000 +MAX_COUNT_FOR_INITIAL_LOG = 10 PICK_FIRST_ROOT: int = 0 PICK_LAST_ROOT: int = -1 @@ -27,3 +31,18 @@ class GitFormatPatterns(Enum): FULL_MESSAGE = "%B" # subject NOT included MESSAGE_BODY = "%b" + + +class SquashMergeDetection(Enum): + NONE = "none" + SIMPLE = "simple" + EXACT = "exact" + + @staticmethod + def from_string(value: str, from_where: Optional[str]) -> 'SquashMergeDetection': + try: + return SquashMergeDetection[value.upper()] + except KeyError: + valid_values = ', '.join(e.value for e in SquashMergeDetection) + prefix = f"Invalid value for {from_where}" if from_where else "Invalid value" + raise MacheteException(f"{prefix}: `{value}`. Valid values are `{valid_values}`") diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py index ebc99ca81..8a986bce3 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -285,6 +285,25 @@ There should be no need for the user to interact with this key directly, `git machete fork-point` with flags should be used instead. + `machete.squashMergeDetection`: + + Controls the algorithm used to detect squash merges. Possible values are: + + * `none`: Fastest mode, with no squash merge/rebase detection. Only strict (fast-forward or 2-parent) merges are detected. + + * `simple` (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. + This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what's in the downstream branch. + + * `exact`: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. + This detects squash merges in more cases than `simple` mode. + However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. + + This has an impact on: + + * whether a grey edge is displayed in `status`, + + * whether `traverse` suggests to slide out the branch. + `machete.status.extraSpaceBeforeBranchName`: To make it easier to select branch name from the `status` output on certain terminals @@ -334,6 +353,9 @@ `GITHUB_TOKEN` Used to store GitHub API token. Used by commands such as `anno --sync-github-prs` and `github`. + `GITLAB_TOKEN` + Used to store GitLab API token. Used by commands such as `anno --sync-gitlab-prs` and `gitlab`. + """, "delete-unmanaged": """ Usage: @@ -1053,7 +1075,9 @@ "slide-out": """ Usage: git machete slide-out --removed-from-remote [--delete] - git machete slide-out [-d|--down-fork-point=] [--delete] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] [ [ [ ...]]] + git machete slide-out [-d|--down-fork-point=] [--delete] + [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] + [ [ [ ...]]] Removes the given branch (or multiple branches) from the branch layout. If no branch has been specified, current branch is slid out. @@ -1155,7 +1179,9 @@ """, "status": """ Usage: - git machete s[tatus] [--color=WHEN] [-l|--list-commits] [-L|--list-commits-with-hashes] [--no-detect-squash-merges] + git machete s[tatus] [--color=WHEN] + [-l|--list-commits] [-L|--list-commits-with-hashes] + [--squash-merge-detection=MODE] Displays a tree-shaped status of the branches listed in the branch layout file. @@ -1229,10 +1255,34 @@ -L, --list-commits-with-hashes Additionally list the short hashes and messages of commits introduced on each branch. --no-detect-squash-merges + Deprecated, use `--squash-merge-detection=none` instead. Only consider strict (fast-forward or 2-parent) merges, rather than rebase/squash merges, when detecting if a branch is merged into its upstream (parent). + --squash-merge-detection=MODE + Specify the mode for detection of rebase/squash merges (grey edges). + `MODE` can be `none` (fastest, no squash merges are detected), `simple` (default) or `exact` (slowest). + See the below paragraph on `machete.squashMergeDetection` git config key for more details. Git config keys: + `machete.squashMergeDetection`: + + Controls the algorithm used to detect squash merges. Possible values are: + + * `none`: Fastest mode, with no squash merge/rebase detection. Only strict (fast-forward or 2-parent) merges are detected. + + * `simple` (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. + This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what's in the downstream branch. + + * `exact`: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. + This detects squash merges in more cases than `simple` mode. + However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. + + This has an impact on: + + * whether a grey edge is displayed in `status`, + + * whether `traverse` suggests to slide out the branch. + `machete.status.extraSpaceBeforeBranchName` To make it easier to select branch name from the `status` output on certain terminals @@ -1259,9 +1309,9 @@ "traverse": """ Usage: git machete t[raverse] [-F|--fetch] [-l|--list-commits] [-M|--merge] - [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges] - [--[no-]push] [--[no-]push-untracked] - [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes] + [-n|--no-edit-merge|--no-interactive-rebase] [--[no-]push] [--[no-]push-untracked] + [--return-to=WHERE] [--start-from=WHERE] [--squash-merge-detection=MODE] + [-w|--whole] [-W] [-y|--yes] Traverses the branches in the order as they occur in branch layout file. By default, `traverse` starts from the current branch. @@ -1291,7 +1341,7 @@ If the traverse flow is stopped (typically due to merge/rebase conflicts), just run `git machete traverse` after the merge/rebase is finished. It will pick up the walk from the current branch. Unlike with `git rebase` or `git cherry-pick`, there is no special `--continue` flag, as `traverse` is stateless. - `traverse` doesn't keep a state of its own like `git rebase` does in `.git/rebase-apply/`. + `traverse` does not keep a state of its own like `git rebase` does in `.git/rebase-apply/`. The rebase, push and slide-out behaviors of `traverse` can also be customized for each branch separately using branch qualifiers. There are `push=no`, `rebase=no` and `slide-out=no` qualifiers that can be used to opt out of default behavior (rebasing, pushing and sliding the branch out). @@ -1320,6 +1370,7 @@ -n If updating by rebase, equivalent to `--no-interactive-rebase`. If updating by merge, equivalent to `--no-edit-merge`. --no-detect-squash-merges + Deprecated, use `--squash-merge-detection=none` instead. Only consider strict (fast-forward or 2-parent) merges, rather than rebase/squash merges, when detecting if a branch is merged into its upstream (parent). --no-edit-merge @@ -1344,6 +1395,10 @@ (nearest remaining branch in case the `here` branch has been slid out by the traversal) or `stay` (the default — just stay wherever the traversal stops). Note: when user quits by `q`/`yq` or when traversal is stopped because one of git actions fails, the behavior is always `stay`. + --squash-merge-detection=MODE + Specifies the mode for detection of rebase/squash merges (grey edges). + `MODE` can be `none` (fastest, no squash merges are detected), `simple` (default) or `exact` (slowest). + See the below paragraph on `machete.squashMergeDetection` git config key for more details. --start-from=WHERE Specifies the branch to start the traversal from; WHERE can be `here` (the default — current branch, must be managed by git machete), `root` (root branch of the current branch, @@ -1363,6 +1418,25 @@ Example: `GIT_MACHETE_REBASE_OPTS="--keep-empty --rebase-merges" git machete traverse`. Git config keys: + `machete.squashMergeDetection`: + + Controls the algorithm used to detect squash merges. Possible values are: + + * `none`: Fastest mode, with no squash merge/rebase detection. Only strict (fast-forward or 2-parent) merges are detected. + + * `simple` (default): Compares the tree (files & directories in the commit) of the downstream branch with the trees of the upstream branch. + This detects squash merges/rebases as long as there exists a squash/rebase commit in the upstream that has the identical tree to what's in the downstream branch. + + * `exact`: Compares the patch (diff introduced by the commits) of the downstream branch with the patches of the upstream branch. + This detects squash merges in more cases than `simple` mode. + However, it might have a significant performance impact on large repositories as it requires computing patches for commits in the upstream branch. + + This has an impact on: + + * whether a grey edge is displayed in `status`, + + * whether `traverse` suggests to slide out the branch. + `machete.traverse.push` To change the behavior of `git machete traverse` command so that it doesn't push branches by default, @@ -1373,7 +1447,7 @@ """, "update": """ Usage: - git machete update [-f|--fork-point=] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] + git machete update [-f|--fork-point=] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] Synchronizes the current branch with its upstream (parent) branch either by rebase (default) or by merge (if `--merge` option passed). diff --git a/git_machete/git_config_keys.py b/git_machete/git_config_keys.py index 9aaa29e2c..63d290ce9 100644 --- a/git_machete/git_config_keys.py +++ b/git_machete/git_config_keys.py @@ -1,6 +1,7 @@ STATUS_EXTRA_SPACE_BEFORE_BRANCH_NAME = 'machete.status.extraSpaceBeforeBranchName' TRAVERSE_PUSH = 'machete.traverse.push' WORKTREE_USE_TOP_LEVEL_MACHETE_FILE = 'machete.worktree.useTopLevelMacheteFile' +SQUASH_MERGE_DETECTION = 'machete.squashMergeDetection' def override_fork_point_to(branch: str) -> str: diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py index b13c1bc78..ec6f81b02 100644 --- a/git_machete/git_operations.py +++ b/git_machete/git_operations.py @@ -8,7 +8,8 @@ Set, Tuple) from . import utils -from .constants import (MAX_COUNT_FOR_INITIAL_LOG, GitFormatPatterns, +from .constants import (MAX_COMMITS_FOR_SQUASH_MERGE_DETECTION, + MAX_COUNT_FOR_INITIAL_LOG, GitFormatPatterns, SyncToRemoteStatuses) from .exceptions import UnderlyingGitException, UnexpectedMacheteException from .utils import (AnsiEscapeCodes, CommandResult, colored, debug, fmt, @@ -148,6 +149,15 @@ def of(value: str) -> Optional["FullTreeHash"]: return FullTreeHash(value) +class FullPatchId(str): + @staticmethod + def of(value: str) -> Optional["FullPatchId"]: + if not value: + raise UnexpectedMacheteException( + f'FullPatchId.of should not accept {value} as a param.') + return FullPatchId(value) + + class ForkPointOverrideData: def __init__(self, to_hash: FullCommitHash): self.to_hash: FullCommitHash = to_hash @@ -188,6 +198,7 @@ def __init__(self) -> None: self.__counterparts_for_fetching_cached: Optional[Dict[LocalBranchShortName, Optional[RemoteBranchShortName]]] = None self.__fetch_done_for: Set[str] = set() self.__initial_log_hashes_cached: Dict[FullCommitHash, List[FullCommitHash]] = {} + self.__is_equivalent_patch_reachable_cached: Dict[Tuple[FullCommitHash, FullCommitHash], bool] = {} self.__is_equivalent_tree_reachable_cached: Dict[Tuple[FullCommitHash, FullCommitHash], bool] = {} self.__local_branches_cached: Optional[List[LocalBranchShortName]] = None self.__merge_base_cached: Dict[Tuple[FullCommitHash, FullCommitHash], Optional[FullCommitHash]] = {} @@ -223,8 +234,8 @@ def _run_git(self, git_cmd: str, *args: str, flush_caches: bool, allow_non_zero: return exit_code def _popen_git(self, git_cmd: str, *args: str, - allow_non_zero: bool = False, env: Optional[Dict[str, str]] = None) -> CommandResult: - exit_code, stdout, stderr = utils.popen_cmd("git", git_cmd, *args, env=env) + allow_non_zero: bool = False, env: Optional[Dict[str, str]] = None, input: Optional[str] = None) -> CommandResult: + exit_code, stdout, stderr = utils.popen_cmd("git", git_cmd, *args, env=env, input=input) if not allow_non_zero and exit_code != 0: exit_code_msg: str = fmt(f"`{utils.get_cmd_shell_repr('git', git_cmd, *args, env=env)}` returned {exit_code}\n") stdout_msg: str = f"\n{utils.bold('stdout')}:\n{utils.dim(stdout)}" if stdout else "" @@ -790,6 +801,8 @@ def is_equivalent_tree_reachable( equivalent_to_commit_hash = self.get_commit_hash_by_revision(equivalent_to) reachable_from_commit_hash = self.get_commit_hash_by_revision(reachable_from) if not equivalent_to_commit_hash or not reachable_from_commit_hash: + # Case not covered by tests, unlikely to be reached by an actual execution. + # Mostly here to satisfy mypy. return False if equivalent_to_commit_hash == reachable_from_commit_hash: @@ -798,11 +811,11 @@ def is_equivalent_tree_reachable( if (equivalent_to_commit_hash, reachable_from_commit_hash) in self.__is_equivalent_tree_reachable_cached: return self.__is_equivalent_tree_reachable_cached[equivalent_to_commit_hash, reachable_from_commit_hash] - earlier_tree_hash = self.get_tree_hash_by_commit_hash(equivalent_to_commit_hash) + tree_hash_for_equivalent_to = self.get_tree_hash_by_commit_hash(equivalent_to_commit_hash) # `git log ^equivalent_to_commit_hash reachable_from_commit_hash` # shows all commits reachable from reachable_from_commit_hash but NOT from equivalent_to_commit_hash - intermediate_tree_hashes = utils.get_non_empty_lines( + tree_hashes_for_reachable_from = utils.get_non_empty_lines( self._popen_git( "log", "--format=%T", # full commit's tree hash @@ -811,11 +824,72 @@ def is_equivalent_tree_reachable( ).stdout ) - result = earlier_tree_hash in intermediate_tree_hashes - debug(f"result = {result}") + result = tree_hash_for_equivalent_to in tree_hashes_for_reachable_from + debug(f"tree_hash_for_equivalent_to in tree_hashes_for_reachable_from = {result}") self.__is_equivalent_tree_reachable_cached[equivalent_to_commit_hash, reachable_from_commit_hash] = result return result + def is_equivalent_patch_reachable( + self, + equivalent_to: AnyRevision, + reachable_from: AnyRevision + ) -> bool: + equivalent_to_commit_hash = self.get_commit_hash_by_revision(equivalent_to) + reachable_from_commit_hash = self.get_commit_hash_by_revision(reachable_from) + if not equivalent_to_commit_hash or not reachable_from_commit_hash: + # Case not covered by tests, unlikely to be reached by an actual execution. + # Mostly here to satisfy mypy. + return False + + if equivalent_to_commit_hash == reachable_from_commit_hash: + return True + + if (equivalent_to_commit_hash, reachable_from_commit_hash) in self.__is_equivalent_patch_reachable_cached: + return self.__is_equivalent_patch_reachable_cached[equivalent_to_commit_hash, reachable_from_commit_hash] + + common_ancestor = self.get_merge_base(reachable_from_commit_hash, equivalent_to_commit_hash) + if not common_ancestor: + return False + + changes_of_equivalent_to = self._popen_git( + "diff", + common_ancestor, + equivalent_to_commit_hash + ).stdout + if changes_of_equivalent_to.strip() == '': + # Empty changeset means the branches are identical, so the tree is equivalent. + self.__is_equivalent_patch_reachable_cached[equivalent_to_commit_hash, reachable_from_commit_hash] = True + return True + + patch_id_for_changes_of_equivalent_to: Optional[FullPatchId] = self.__get_patch_id_for_diff(changes_of_equivalent_to) + patch_ids_for_commits_of_reachable_from: Set[FullPatchId] = set(self.__get_patch_ids_for_commits_between( + common_ancestor, reachable_from_commit_hash, MAX_COMMITS_FOR_SQUASH_MERGE_DETECTION).values()) + result = patch_id_for_changes_of_equivalent_to in patch_ids_for_commits_of_reachable_from + debug(f"patch_id_for_changes_of_equivalent_to in patch_ids_for_commits_of_reachable_from = {result}") + self.__is_equivalent_patch_reachable_cached[equivalent_to_commit_hash, reachable_from_commit_hash] = result + return result + + def __get_patch_id_for_diff(self, patch_contents: str) -> Optional[FullPatchId]: + out = utils.get_non_empty_lines(self._popen_git("patch-id", input=patch_contents).stdout) + + if len(out) == 0: + # Line uncovered as we actually always pass a non-empty patch to this method. + return None + return FullPatchId.of(out[0].split(' ')[0]) # patch-id output is " ", we only care about the patch-id + + def __get_patch_ids_for_commits_between( + self, earliest_exclusive: AnyRevision, latest_inclusive: AnyRevision, max_commits: int + ) -> Dict[FullCommitHash, FullPatchId]: + patches = self._popen_git("log", "--patch", f"^{earliest_exclusive}", latest_inclusive, f"-{max_commits}", "--").stdout + patch_ids = self._popen_git("patch-id", input=patches).stdout + + patch_id_for_commit: Dict[FullCommitHash, FullPatchId] = {} + for line in patch_ids.splitlines(): + patch_id, commit_hash = line.strip().split(" ", 1) + patch_id_for_commit[FullCommitHash.of(commit_hash)] = FullPatchId(patch_id) + + return patch_id_for_commit + def get_sole_remote_branch(self, branch: LocalBranchShortName) -> Optional[RemoteBranchShortName]: remote_branches = self.get_remote_branches() matching_remotes = [remote for remote in self.get_remotes() if (remote + "/" + branch) in remote_branches] diff --git a/git_machete/github.py b/git_machete/github.py index 373848e72..3bd049d56 100644 --- a/git_machete/github.py +++ b/git_machete/github.py @@ -253,7 +253,7 @@ def __fire_github_api_request(self, method: str, path: str, request_body: Option if self.__token: raise MacheteException( first_line + f'Make sure that the GitHub API token provided by {self.__token.provider} ' - f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n' + last_line) + f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n' + last_line) else: raise MacheteException( first_line + 'You might not have the required permissions for this repository.\n' diff --git a/git_machete/gitlab.py b/git_machete/gitlab.py index fb2622bc5..afb97ee1b 100644 --- a/git_machete/gitlab.py +++ b/git_machete/gitlab.py @@ -191,7 +191,7 @@ def __fire_gitlab_api_request(self, method: str, path: str, request_body: Option if self.__token: raise MacheteException( first_line + f'Make sure that the GitLab API token provided by {self.__token.provider} ' - f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n' + last_line) + f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n' + last_line) else: raise MacheteException( first_line + 'You might not have the required permissions for this project.\n' diff --git a/git_machete/options.py b/git_machete/options.py index 3895b8dfb..2d4d5935a 100644 --- a/git_machete/options.py +++ b/git_machete/options.py @@ -21,7 +21,8 @@ def __init__(self) -> None: self.opt_list_commits_with_hashes: bool = False self.opt_merge: bool = False self.opt_n: bool = False - self.opt_no_detect_squash_merges: bool = False + self.opt_squash_merge_detection_string: str = "simple" + self.opt_squash_merge_detection_origin: Optional[str] = None self.opt_no_edit_merge: bool = False self.opt_no_interactive_rebase: bool = False self.opt_onto: Optional[LocalBranchShortName] = None diff --git a/git_machete/utils.py b/git_machete/utils.py index 12b9d1fa8..af0f430a3 100644 --- a/git_machete/utils.py +++ b/git_machete/utils.py @@ -187,10 +187,13 @@ class PopenResult(NamedTuple): def _popen_cmd(cmd: str, *args: str, - cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> PopenResult: + cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None, input: Optional[str] = None) -> PopenResult: + stdin = subprocess.PIPE if input is not None else None + input_bytes = input.encode('utf-8') if input else None + # capture_output argument is only supported since Python 3.7 - process = subprocess.Popen([cmd] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env) - stdout_bytes, stderr_bytes = process.communicate() + process = subprocess.Popen([cmd] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=stdin, cwd=cwd, env=env) + stdout_bytes, stderr_bytes = process.communicate(input_bytes) exit_code: int = process.returncode # must be retrieved after process.communicate() stdout: str = stdout_bytes.decode('utf-8') stderr: str = stderr_bytes.decode('utf-8') @@ -198,7 +201,7 @@ def _popen_cmd(cmd: str, *args: str, def popen_cmd(cmd: str, *args: str, cwd: Optional[str] = None, - env: Optional[Dict[str, str]] = None, hide_debug_output: bool = False) -> PopenResult: + env: Optional[Dict[str, str]] = None, hide_debug_output: bool = False, input: Optional[str] = None) -> PopenResult: chdir_upwards_until_current_directory_exists() flat_cmd = get_cmd_shell_repr(cmd, *args, env=env) @@ -215,7 +218,7 @@ def print_command(cmd: str) -> None: print_command(flat_cmd) start = time.time() - exit_code, stdout, stderr = result = _popen_cmd(cmd, *args, cwd=cwd, env=env) + exit_code, stdout, stderr = result = _popen_cmd(cmd, *args, cwd=cwd, env=env, input=input) if measure_command_time: # pragma: no cover end = time.time() elapsed_ms = int((end - start) * 1e3) diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py index 8f9fe2844..cbe266c66 100644 --- a/tests/test_git_operations.py +++ b/tests/test_git_operations.py @@ -45,3 +45,122 @@ def is_commit_present_in_repository(revision: AnyRevision) -> bool: later=LocalBranchShortName('master')) is False assert self.repo_sandbox.is_ancestor_or_equal(earlier=LocalBranchShortName('develop'), later=LocalBranchShortName('feature')) is True + + def test_is_equivalent_tree_or_patch_reachable_with_squash_merge(self) -> None: + ( + self.repo_sandbox.new_branch("master") + .commit("master first commit") + .new_branch("feature") + .commit("feature commit") + .check_out("master") + ) + + git = GitContext() + assert git._run_git("merge", "--squash", "feature", flush_caches=False) == 0 + assert git._run_git("commit", "-m", "squashed", flush_caches=False) == 0 + + feature = AnyRevision("feature") + master = AnyRevision("master") + + # Both methods should return True, as there are no commits in master before we merged feature + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is True + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + def test_is_equivalent_tree_or_patch_reachable_with_squash_merge_and_commits_in_between(self) -> None: + ( + self.repo_sandbox.new_branch("master") + .commit("master first commit") + .new_branch("feature") + .commit("feature commit") + .check_out("master") + .commit("extra commit") + ) + + git = GitContext() + assert git._run_git("merge", "--squash", "feature", flush_caches=False) == 0 + assert git._run_git("commit", "-m", "squashed", flush_caches=False) == 0 + + feature = AnyRevision("feature") + master = AnyRevision("master") + + # Here the simple method will not detect the squash merge, as there are commits in master before we merged feature so + # there's no tree hash in master that matches the tree hash of feature + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + self.repo_sandbox.check_out("master").commit("another master commit") + git.flush_caches() # so that the old position of `master` isn't remembered + + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + def test_is_equivalent_tree_or_patch_reachable_with_rebase(self) -> None: + ( + self.repo_sandbox.new_branch("master") + .commit("master first commit") + .new_branch("feature") + .commit("feature commit") + .check_out("master") + ) + + git = GitContext() + assert git._run_git("rebase", "feature", flush_caches=False) == 0 + + feature = AnyRevision("feature") + master = AnyRevision("master") + + # Same as merge example, both methods should return True as there are no commits in master before we rebased feature + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is True + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + self.repo_sandbox.check_out("master").commit("another master commit") + git.flush_caches() # so that the old position of `master` isn't remembered + + # Simple method fails if there are commits after the rebase, as this case is covered by the "is ancestor" + # check in the is_merged_to method in client.py + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + def test_is_equivalent_tree_or_patch_reachable_with_rebase_and_commits_in_between(self) -> None: + ( + self.repo_sandbox.new_branch("master") + .commit("master first commit") + .new_branch("feature") + .commit("feature commit") + .check_out("master") + .commit("extra commit") + ) + + git = GitContext() + assert git._run_git("rebase", "feature", flush_caches=False) == 0 + + feature = AnyRevision("feature") + master = AnyRevision("master") + + # Same as the merge example, the simple method will not detect the rebase, as there are commits + # in master before we rebased feature and tree hashes are always different + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + self.repo_sandbox.check_out("master").commit("another master commit") + git.flush_caches() # so that the old position of `master` isn't remembered + + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + # To cover retrieval of the result from cache + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is True + + def test_is_equivalent_tree_or_patch_reachable_when_no_common_ancestor(self) -> None: + ( + self.repo_sandbox.new_branch("master") + .commit("master first commit") + .new_orphan_branch("feature") + .commit("feature commit") + ) + + git = GitContext() + feature = AnyRevision("feature") + master = AnyRevision("master") + + assert git.is_equivalent_tree_reachable(equivalent_to=feature, reachable_from=master) is False + assert git.is_equivalent_patch_reachable(equivalent_to=feature, reachable_from=master) is False diff --git a/tests/test_help.py b/tests/test_help.py index 92373ba79..1eb7c68ae 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -16,7 +16,7 @@ def test_help(self) -> None: launch_command() assert ExitCode.ARGUMENT_ERROR == e.value.code - launch_command("help") + assert "--verbose" in launch_command("help") with pytest.raises(SystemExit) as e: launch_command("help", "no-such-command") @@ -30,7 +30,7 @@ def test_help(self) -> None: if command not in help_topics: with pytest.raises(SystemExit) as e: - launch_command(command, "--help") + assert "Usage:" in launch_command(command, "--help") assert ExitCode.SUCCESS == e.value.code else: with pytest.raises(SystemExit) as e: @@ -41,3 +41,8 @@ def test_help_output_has_no_ansi_codes(self) -> None: for command in commands_and_aliases: help_output = launch_command('help', command) assert '\033' not in help_output + + def test_help_succeeds_despite_invalid_git_config_key(self) -> None: + self.repo_sandbox.set_git_config_key("machete.squashMergeDetection", "invalid") + help_output = launch_command('help') + assert "Usage:" in help_output diff --git a/tests/test_status.py b/tests/test_status.py index 70788c49e..1c7f2fc38 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -306,7 +306,7 @@ def test_extra_space_before_branch_name(self) -> None: ) assert_success(['status'], expected_status_output) - def test_squashed_branch_recognized_as_merged_with_traverse(self) -> None: + def test_status_squashed_branch_recognized_as_merged_with_traverse(self) -> None: ( self.repo_sandbox.new_branch("root") @@ -378,10 +378,8 @@ def test_squashed_branch_recognized_as_merged_with_traverse(self) -> None: """, ) - # but under --no-detect-squash-merges, feature is detected as "x" (behind) develop - assert_success( - ["status", "-l", "--no-detect-squash-merges"], - """ + # under --squash-merge-detection=none, feature is detected as "x" (out of sync) with develop + expected_output_detection_none = """ root | | develop @@ -395,8 +393,27 @@ def test_squashed_branch_recognized_as_merged_with_traverse(self) -> None: | child_1 | child_2 o-child * - """, + """ + assert_success( + ["status", "-l", "--no-detect-squash-merges"], + " Warn: --no-detect-squash-merges is deprecated, " + "use --squash-merge-detection=none instead\n" + expected_output_detection_none + ) + assert_success( + ["status", "-l", "--squash-merge-detection=none"], + expected_output_detection_none + ) + self.repo_sandbox.set_git_config_key('machete.squashMergeDetection', 'none') + assert_success( + ["status", "-l"], + expected_output_detection_none ) + self.repo_sandbox.set_git_config_key('machete.squashMergeDetection', 'lolxd') + assert_failure( + ["status", "-l"], + "Invalid value for machete.squashMergeDetection git config key: lolxd. Valid values are none, simple, exact" + ) + self.repo_sandbox.unset_git_config_key('machete.squashMergeDetection') # traverse then slide out the feature branch launch_command("traverse", "-w", "-y") @@ -458,6 +475,55 @@ def test_squashed_branch_recognized_as_merged_with_traverse(self) -> None: """, ) + def test_status_for_squash_merge_and_commits_in_between(self) -> None: + ( + self.repo_sandbox + .new_branch("master") + .remove_remote("origin") + .commit("master first commit") + .new_branch("feature") + .commit("feature commit") + .check_out("master") + .commit("extra commit") + .execute("git merge --squash feature") + .execute("git commit -m squashed") + ) + + body: str = \ + """ + master + feature + """ + rewrite_branch_layout_file(body) + + # Here the simple method will not detect the squash merge, as there are commits in master before we merged feature so + # there's no tree hash in master that matches the tree hash of feature + expected_status_output_simple = ( + """ + master * + | + x-feature + """ + ) + assert_success(['status'], expected_status_output_simple) + assert_success(['status', '--squash-merge-detection=simple'], expected_status_output_simple) + expected_status_output_exact = ( + """ + master * + | + m-feature + """ + ) + assert_success(['status', '--squash-merge-detection=exact'], expected_status_output_exact) + self.repo_sandbox.set_git_config_key('machete.squashMergeDetection', 'exact') + assert_success(['status'], expected_status_output_exact) + + def test_status_invalid_squash_merge_detection(self) -> None: + assert_failure(["status", "--squash-merge-detection=invalid"], + "Invalid value for --squash-merge-detection flag: invalid. Valid values are none, simple, exact") + assert_failure(["status", "--squash-merge-detection=none", "--squash-merge-detection=invalid"], + "Invalid value for --squash-merge-detection flag: invalid. Valid values are none, simple, exact") + def test_status_inferring_counterpart_for_fetching_of_branch(self) -> None: origin_1_remote_path = self.repo_sandbox.create_repo("remote-1", bare=True) ( diff --git a/tests/test_traverse.py b/tests/test_traverse.py index f8b365c6f..c573e03c3 100644 --- a/tests/test_traverse.py +++ b/tests/test_traverse.py @@ -986,11 +986,12 @@ def test_traverse_no_managed_branches(self) -> None: assert_failure(["traverse"], expected_error_message) def test_traverse_invalid_flag_values(self) -> None: - self.setup_standard_tree() - assert_failure(["traverse", "--start-from=nowhere"], - "Invalid argument for --start-from. Valid arguments: here|root|first-root.") assert_failure(["traverse", "--return-to=dunno-where"], - "Invalid argument for --return-to. Valid arguments: here|nearest-remaining|stay.") + "Invalid value for --return-to flag: dunno-where. Valid values are here, nearest-remaining, stay") + assert_failure(["traverse", "--squash-merge-detection=lolxd"], + "Invalid value for --squash-merge-detection flag: lolxd. Valid values are none, simple, exact") + assert_failure(["traverse", "--start-from=nowhere"], + "Invalid value for --start-from flag: nowhere. Valid values are here, root, first-root") def test_traverse_removes_current_directory(self) -> None: (