Repository: VirtusLab/git-machete
Pull request #1238 (2024-04-26 08:18:16)

Improve squash merge detection
This PR improves detection of squash merges. The existing logic only works if there are no commits in between the fork point and the merge, which in busy repositories might not be the case. I've added direct comparison of diffs using `git patch-id`, which should be reasonably stable. 

The change adds some delay in `git machete status` as there are more commands being executed. For my repos this doesn't seem to be above "barely noticeable", but the impact could be worse when there are more commits to compare.

Also, in order to be able to run `patch_id` I've modified `_popen_git` and subsequent functions so they accept standard input, I hope that's ok.



PawelLipski: Wow 😯  I'm gonna check how it performs on my large commercial repo (~800k commits)
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1238?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) Report
Attention: Patch coverage is `93.75000%` with `2 lines` in your changes are missing coverage. Please review.
> Project coverage is 98.48%. Comparing base [(`77c52db`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/77c52db8f59fe1fe2b4fbcf9908a389bcbed4c0b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) to head [(`f24e9fd`)](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1238?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).

| [Files](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1238?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) | Patch % | Lines |
|---|---|---|
| [git\_machete/git\_operations.py](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1238?src=pr&el=tree&filepath=git_machete%2Fgit_operations.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab#diff-Z2l0X21hY2hldGUvZ2l0X29wZXJhdGlvbnMucHk=) | 92.59% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) |

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


```diff
@@             Coverage Diff             @@
##           develop    #1238      +/-   ##
===========================================
+ Coverage    98.47%   98.48%   +0.01%     
===========================================
  Files           14       14              
  Lines         3679     3706      +27     
  Branches       804      811       +7     
===========================================
+ Hits          3623     3650      +27     
  Misses          37       37              
  Partials        19       19              
```



</details>

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

gjulianm: > Wow 😯 I'm gonna check how it performs on my large commercial repo (~800k commits)

My main worry is [this loop](https://github.com/VirtusLab/git-machete/pull/1238/files#diff-a50d300abea7f8b757dda0e66fc48839620aa8b04384556359a39fa3783f09b3R831-R836). For very long-lived branches that are way behind their fork point it could be a problem, I was thinking that maybe I could set a limit of commits (e.g., if we don't see the squash merge in the first 100 commits, then assume it's not merged).
PawelLipski: > > Wow 😯 I'm gonna check how it performs on my large commercial repo (~800k commits)
> 
> My main worry is [this loop](https://github.com/VirtusLab/git-machete/pull/1238/files#diff-a50d300abea7f8b757dda0e66fc48839620aa8b04384556359a39fa3783f09b3R831-R836). For very long-lived branches that are way behind their fork point it could be a problem, I was thinking that maybe I could set a limit of commits (e.g., if we don't see the squash merge in the first 100 commits, then assume it's not merged).

Whoops invoking `git diff` in a loop will indeed be troublesome :/ currently the number of invoked git commands is `O(N)` with respect to branch layout size (even though if some individual commands like `git log` might be linear wrt. git log size) ☹️  
PawelLipski: Actually... stepping back a bit, how often did you encounter the case when a squash merge has not been detected (but is now detected after your PR)? Could you provide a quick example how that situation looked like? it's been a long time since I've touched that logic TBH 🤔

gjulianm: > Actually... stepping back a bit, how often did you encounter the case when a squash merge has not been detected (but is now detected after your PR)? Could you provide a quick example how that situation looked like? it's been a long time since I've touched that logic TBH 🤔

Quite often, in the main repo I use there are always commits between the fork point and the squash merge. I validated with the tests I added that indeed that was the case, if you remove the new logic (e.g., forcing an early exit [here](https://github.com/VirtusLab/git-machete/pull/1238/files#diff-a50d300abea7f8b757dda0e66fc48839620aa8b04384556359a39fa3783f09b3R809) changing `if result` to `if result or True`) the tests that add commits in between fail.

Up to this point I was manually sliding out merged branches because I thought it was a problem on how the merges were done in the repo, but then I noticed it only happened in busy repos, and that's how I found out the problem were those intermediate merges.
gjulianm: > Huh okay... would you mind putting this logic behind a [git config key](https://git-machete.readthedocs.io/en/stable/#config)? 🤔 flag to `status` can also be considered instead, as there's already `--no-detect-squash-merges`. Maybe sth like `--squash-merge-detection={none,simple,exact}`? And then `--no-detect-squash-merges` to be deprecated.

Sounds good, I'll take a look, thx!
gjulianm: Added the option, let me know if it looks good. I wasn't sure if some of the docs are automatic or not (e.g. the traverse/status help pages) so I didn't update them. Also, not sure if the deprecation is how you expected.

I'll take a look later regarding the use of `git log -p` to get all the patches in one git call.
gjulianm: Now it's using `git log -p', we still have to call `git patch-id` for each changeset though, I didn't see any option to get the patch-id from the `git log` call directly.
PawelLipski: I'm gonna introduce minor fixes directly onto your branch. Sorry for the lags, I'm busy as heck in my commercial job :/
gjulianm: > I'm gonna introduce minor fixes directly onto your branch. Sorry for the lags, I'm busy as heck in my commercial job :/

No problem, thank you very much for the help and for the tool too :)

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 17db617..6809bee 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` &mdash; 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 204112e..bcf66a6 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.<branch>.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=<down\-fork\-point\-commit>] [\-\-delete] [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase] [<branch> [<branch> [<branch> ...]]]
+git machete slide\-out [\-d|\-\-down\-fork\-point=<down\-fork\-point\-commit>] [\-\-delete]
+                      [\-M|\-\-merge] [\-n|\-\-no\-edit\-merge|\-\-no\-interactive\-rebase]
+                      [<branch> [<branch> [<branch> ...]]]
 .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=<fork\-point\-commit>] [\-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 3e546d8..6c3e93f 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 3a57016..9e09a55 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=<down-fork-point-commit>] [--delete] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] [<branch> [<branch> [<branch> ...]]]
+    git machete slide-out [-d|--down-fork-point=<down-fork-point-commit>] [--delete]
+                          [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase]
+                          [<branch> [<branch> [<branch> ...]]]
 
 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 d7c9921..8149aab 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 92c538b..3a66a96 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 f30bca4..8749659 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=<fork-point-commit>] [-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 0000000..7f06e2e
--- /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 6889cb4..c1292df 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.25.3'
+__version__ = '3.26.0'
diff --git a/git_machete/cli.py b/git_machete/cli.py
index 7de7057..e652be7 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
 
 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 create_cli_parser() -> argparse.ArgumentParser:
     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 create_cli_parser() -> argparse.ArgumentParser:
     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 launch(orig_args: List[str]) -> None:
                 )
             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 7e8782d..742a326 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -17,7 +17,7 @@ from .code_hosting import (CodeHostingClient, CodeHostingSpec,
                            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 @@ class MacheteClient:
                 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 @@ class MacheteClient:
             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 @@ class MacheteClient:
 
         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 @@ class MacheteClient:
             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 @@ class MacheteClient:
             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 @@ class MacheteClient:
                     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 @@ class MacheteClient:
             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 @@ class MacheteClient:
             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 @@ class MacheteClient:
             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 @@ class MacheteClient:
                             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 @@ class MacheteClient:
             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 @@ class MacheteClient:
         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 @@ class MacheteClient:
     ) -> 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 @@ class MacheteClient:
         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 @@ class MacheteClient:
             # (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 @@ class MacheteClient:
                     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 @@ class MacheteClient:
                     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 5e20ec2..54c6156 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 ebc99ca..8a986bc 100644
--- a/git_machete/generated_docs.py
+++ b/git_machete/generated_docs.py
@@ -285,6 +285,25 @@ long_docs: Dict[str, str] = {
               There should be <b>no</b> 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 @@ long_docs: Dict[str, str] = {
            `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": """
         <b>Usage:</b><b>
@@ -1053,7 +1075,9 @@ long_docs: Dict[str, str] = {
     "slide-out": """
         <b>Usage:</b><b>
            git machete slide-out --removed-from-remote [--delete]
-           git machete slide-out [-d|--down-fork-point=<down-fork-point-commit>] [--delete] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase] [<branch> [<branch> [<branch> ...]]]</b>
+           git machete slide-out [-d|--down-fork-point=<down-fork-point-commit>] [--delete]
+                                 [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase]
+                                 [<branch> [<branch> [<branch> ...]]]</b>
 
         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 @@ long_docs: Dict[str, str] = {
    """,
     "status": """
         <b>Usage:</b><b>
-           git machete s[tatus] [--color=WHEN] [-l|--list-commits] [-L|--list-commits-with-hashes] [--no-detect-squash-merges]</b>
+           git machete s[tatus] [--color=WHEN]
+                                [-l|--list-commits] [-L|--list-commits-with-hashes]
+                                [--squash-merge-detection=MODE]</b>
 
         Displays a tree-shaped status of the branches listed in the branch layout file.
 
@@ -1229,10 +1255,34 @@ long_docs: Dict[str, str] = {
            <b>-L</b>, <b>--list-commits-with-hashes</b>
               Additionally list the short hashes and messages of commits introduced on each branch.
            <b>--no-detect-squash-merges</b>
+              <b>Deprecated</b>, 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).
+           <b>--squash-merge-detection=MODE</b>
+              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.
 
         <b>Git config keys:</b>
+           `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 @@ long_docs: Dict[str, str] = {
     "traverse": """
         <b>Usage:</b><b>
            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]</b>
+                                  [-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]</b>
 
         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 @@ long_docs: Dict[str, str] = {
         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 <b>not</b> 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 @@ long_docs: Dict[str, str] = {
            <b>-n</b>
               If updating by rebase, equivalent to `--no-interactive-rebase`. If updating by merge, equivalent to `--no-edit-merge`.
            <b>--no-detect-squash-merges</b>
+              <b>Deprecated</b>, 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).
            <b>--no-edit-merge</b>
@@ -1344,6 +1395,10 @@ long_docs: Dict[str, str] = {
               (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`.
+           <b>--squash-merge-detection=MODE</b>
+              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.
            <b>--start-from=WHERE</b>
               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 @@ long_docs: Dict[str, str] = {
               Example: `GIT_MACHETE_REBASE_OPTS="--keep-empty --rebase-merges" git machete traverse`.
 
         <b>Git config keys:</b>
+           `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 @@ long_docs: Dict[str, str] = {
    """,
     "update": """
         <b>Usage:</b><b>
-           git machete update [-f|--fork-point=] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase]</b>
+           git machete update [-f|--fork-point=<fork-point-commit>] [-M|--merge] [-n|--no-edit-merge|--no-interactive-rebase]</b>
 
         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 9aaa29e..63d290c 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 b13c1bc..ec6f81b 100644
--- a/git_machete/git_operations.py
+++ b/git_machete/git_operations.py
@@ -8,7 +8,8 @@ from typing import (Any, Dict, Iterator, List, Match, NamedTuple, Optional,
                     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 @@ class FullTreeHash(str):
         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 @@ class GitContext:
         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 @@ class GitContext:
         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 @@ class GitContext:
         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 @@ class GitContext:
         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 @@ class GitContext:
             ).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 "<patch-id> <commit-hash>", 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 373848e..3bd049d 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -253,7 +253,7 @@ class GitHubClient(CodeHostingClient):
                 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 fb2622b..afb97ee 100644
--- a/git_machete/gitlab.py
+++ b/git_machete/gitlab.py
@@ -191,7 +191,7 @@ class GitLabClient(CodeHostingClient):
                 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 3895b8d..2d4d593 100644
--- a/git_machete/options.py
+++ b/git_machete/options.py
@@ -21,7 +21,8 @@ class CommandLineOptions:
         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 12b9d1f..af0f430 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 popen_cmd(cmd: str, *args: str, cwd: Optional[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)


Test patch:
diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py
index 8f9fe28..cbe266c 100644
--- a/tests/test_git_operations.py
+++ b/tests/test_git_operations.py
@@ -45,3 +45,122 @@ class TestGitOperations(BaseTest):
                                                       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 92373ba..1eb7c68 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -16,7 +16,7 @@ class TestHelp(BaseTest):
             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 @@ class TestHelp(BaseTest):
 
             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 @@ class TestHelp(BaseTest):
         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 70788c4..1c7f2fc 100644
--- a/tests/test_status.py
+++ b/tests/test_status.py
@@ -306,7 +306,7 @@ class TestStatus(BaseTest):
         )
         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 @@ class TestStatus(BaseTest):
             """,
         )
 
-        # 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 @@ class TestStatus(BaseTest):
                 | 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 @@ class TestStatus(BaseTest):
             """,
         )
 
+    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 f8b365c..c573e03 100644
--- a/tests/test_traverse.py
+++ b/tests/test_traverse.py
@@ -986,11 +986,12 @@ class TestTraverse(BaseTest):
         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:
         (