Repository: gitguardian/ggshield
Issue: Pre-receive hook does not behave as expected in the event of a push force

## Environment

- ggshield version: `>= 1.13.6` (to have the latest pre-receive scan implementation version)
- Operating system (Linux, macOS, Windows): Any
- Operating system version: Any
- Python version: Any

## Describe the bug

When a user reworks a local branch (typically after rebasing the target branch) and makes a push force, the pre-receive hook will list all commits on the remote branch + all commits on the local branch being pushed force. This is not the expected behavior as the commits on the remote branch are already known to the remote, and hence should have already been handled by the pre-receive hook.  

### Steps to reproduce:

1. Chose a git repository
2. Make two separate clones : one will be called L (local) for convenience, and the other one R (remote).
3. Mark R as being a remote in L : `git remote add my_remote PATH_TO_R/.git`
4. Add a ggshield pre-receive hook in R : Create a `PATH_TO_R/.git/hooks/pre-receive` file containing :  
```
#!/bin/sh
set -e
ggshield --verbose --debug secret scan pre-receive
```
5. Create a branch `pre-receive-test` in L, add a commit (it has a sha : `sha-first-commit`), and push to R. GGShield should run and scan only the commit you added.
6. Now, go in R, and add a new commit on the main branch, we'll call its sha `sha-commit-main-branch`. Go in L, pull changed from main, and rebase `pre-receive-test` on main. You now have one commit on the `pre-receive-test` branch, the same as before, but its sha changed because of the rebase. Let's call it `sha-first-commit-after-rebase`.
7. In L, push force the changes to R : `git push -f my_remote`
8. GGshield pre-receive hook will do the following (in the logs) : `git rev-list --reverse sha-first-commit...sha-first-commit-after-rebase --max-count 51`
9. This results in scanning three commits (`sha-first-commit`, `sha-commit-main-branch`, `sha-first-commit-after-rebase`) instead of scanning only one : `sha-first-commit-after-rebase`.

### Actual result:

Commits from both the remote branch and the local branch being pushed force are scanned

### Expected result:

Only the commits from the branch being pushed are scanned.


Changed files:
tests/functional/secret/test_scan_repo.py, ggshield/cmd/secret/scan/prereceive.py, tests/unit/cmd/scan/test_prereceive.py, tests/functional/secret/test_scan_prepush.py, tests/functional/secret/test_scan_commit_range.py, tests/functional/secret/test_scan_prereceive.py, tests/functional/secret/test_scan_precommit.py, tests/repository.py

diff --git a/ggshield/cmd/secret/scan/prereceive.py b/ggshield/cmd/secret/scan/prereceive.py
index 0bdbcd4..d90a35a 100644
--- a/ggshield/cmd/secret/scan/prereceive.py
+++ b/ggshield/cmd/secret/scan/prereceive.py
@@ -1,4 +1,5 @@
 import _thread as thread
+import logging
 import os
 import sys
 import threading
@@ -22,6 +23,8 @@ from ggshield.scan import ScanContext, ScanMode
 from ggshield.scan.repo import scan_commit_range
 
 
+logger = logging.getLogger(__name__)
+
 REMEDIATION_MESSAGE = """  A pre-receive hook set server side prevented you from pushing secrets.
   Since the secret was detected during the push BUT after the commit, you need to:
   1. rewrite the git history making sure to replace the secret with its reference (e.g. environment variable).
@@ -97,9 +100,10 @@ def find_branch_start(commit: str) -> Optional[str]:
     return None
 
 
-def parse_stdin() -> Tuple[str, str]:
+def parse_stdin() -> Optional[Tuple[str, str]]:
     """
-    Parse stdin and return the first and last commit to scan
+    Parse stdin and return the first and last commit to scan,
+    or None if there is nothing to do
     """
     prereceive_input = sys.stdin.read().strip()
     if not prereceive_input:
@@ -108,16 +112,21 @@ def parse_stdin() -> Tuple[str, str]:
     # TODO There can be more than one line here, for example when pushing multiple
     # branches. We should support this.
     line = prereceive_input.splitlines()[0]
-    old_commit, new_commit, _ = line.split(maxsplit=2)
-
-    if old_commit == EMPTY_SHA:
-        # Pushing to a new branch
-        start_commit = find_branch_start(new_commit)
-        if start_commit is None:
-            # branch does not contain any new commit
-            old_commit = new_commit
-        else:
-            old_commit = f"{start_commit}~1"
+    logger.debug("stdin: %s", line)
+    _old_commit, new_commit, _ = line.split(maxsplit=2)
+
+    if new_commit == EMPTY_SHA:
+        # Deletion event, nothing to do
+        return None
+
+    # ignore _old_commit because in case of a force-push, it is going to be overwritten
+    # and should not be scanned (see #437)
+    start_commit = find_branch_start(new_commit)
+    if start_commit is None:
+        # branch does not contain any new commit
+        old_commit = new_commit
+    else:
+        old_commit = f"{start_commit}~1"
 
     return (old_commit, new_commit)
 
@@ -155,7 +164,12 @@ def prereceive_cmd(
         )
         return 0
 
-    before, after = parse_stdin()
+    before_after = parse_stdin()
+    if before_after is None:
+        click.echo("Deletion event or nothing to scan.", err=True)
+        return 0
+
+    before, after = before_after
     if before == after:
         click.echo(
             "Pushed branch does not contain any new commit.",
@@ -163,24 +177,13 @@ def prereceive_cmd(
         )
         return 0
 
-    if after == EMPTY_SHA:
-        click.echo("Deletion event or nothing to scan.", err=True)
-        return 0
-
     assert before != EMPTY_SHA
+    assert after != EMPTY_SHA
     commit_list = get_list_commit_SHA(
         f"{before}...{after}", max_count=config.max_commits_for_hook + 1
     )
 
-    if not commit_list:
-        click.echo(
-            "Unable to get commit range.\\n"
-            f"  before: {before}\\n"
-            f"  after: {after}\\n"
-            "Skipping pre-receive hook\\n",
-            err=True,
-        )
-        return 0
+    assert commit_list, "Commit list should not be empty at this point"
 
     if len(commit_list) > config.max_commits_for_hook:
         click.echo(
diff --git a/tests/functional/secret/test_scan_commit_range.py b/tests/functional/secret/test_scan_commit_range.py
index bee4c6e..0cc9519 100644
--- a/tests/functional/secret/test_scan_commit_range.py
+++ b/tests/functional/secret/test_scan_commit_range.py
@@ -10,7 +10,7 @@ def test_scan_commit_range(tmp_path: Path) -> None:
     test_file = repo.path / "test"
     for n in range(10):
         test_file.write_text(f"Hello {n}")
-        repo.git("add", test_file)
+        repo.add(test_file)
         repo.create_commit(message=f"Hello {n}")
 
     run_ggshield_scan("commit-range", "HEAD~4...", expected_code=0, cwd=repo.path)
@@ -25,7 +25,7 @@ def test_scan_commit_range_finds_old_leak(tmp_path: Path) -> None:
     secret_file = repo.path / "secret.conf"
     secret_content = f"password = {GG_VALID_TOKEN}"
     secret_file.write_text(secret_content)
-    repo.git("add", "secret.conf")
+    repo.add("secret.conf")
 
     # AND some clean commits on top of it
     for _ in range(3):
diff --git a/tests/functional/secret/test_scan_precommit.py b/tests/functional/secret/test_scan_precommit.py
index 0a48530..97b5964 100644
--- a/tests/functional/secret/test_scan_precommit.py
+++ b/tests/functional/secret/test_scan_precommit.py
@@ -22,7 +22,7 @@ def test_scan_precommit(tmp_path: Path) -> None:
 
     # WHEN I try to commit a secret
     # THEN the hook prevents the commit
-    repo.git("add", "secret.conf")
+    repo.add("secret.conf")
     with pytest.raises(CalledProcessError) as exc:
         repo.create_commit()
 
diff --git a/tests/functional/secret/test_scan_prepush.py b/tests/functional/secret/test_scan_prepush.py
index 30b7a5b..5392f42 100644
--- a/tests/functional/secret/test_scan_prepush.py
+++ b/tests/functional/secret/test_scan_prepush.py
@@ -22,13 +22,13 @@ def test_scan_prepush(tmp_path: Path) -> None:
     secret_file = local_repo.path / "secret.conf"
     secret_content = f"password = {GG_VALID_TOKEN}"
     secret_file.write_text(secret_content)
-    local_repo.git("add", "secret.conf")
+    local_repo.add("secret.conf")
     local_repo.create_commit()
 
     # WHEN I try to push
     # THEN the hook prevents the push
     with pytest.raises(CalledProcessError) as exc:
-        local_repo.git("push")
+        local_repo.push()
 
     # AND the error message contains the leaked secret
     stdout = exc.value.stdout.decode()
@@ -45,7 +45,7 @@ def test_scan_prepush_branch_without_new_commits(tmp_path: Path) -> None:
     # Add a commit to the remote repository, otherwise git complains the branch does not
     # contain anything
     local_repo.create_commit()
-    local_repo.git("push")
+    local_repo.push()
 
     # AND ggshield installed as a pre-push hook
     run_ggshield("install", "-m", "local", "-t", "pre-push", cwd=local_repo.path)
@@ -56,4 +56,4 @@ def test_scan_prepush_branch_without_new_commits(tmp_path: Path) -> None:
 
     # WHEN I try to push the branch
     # THEN the hook does not crash
-    local_repo.git("push", "-u", "origin", branch_name)
+    local_repo.push("-u", "origin", branch_name)
diff --git a/tests/functional/secret/test_scan_prereceive.py b/tests/functional/secret/test_scan_prereceive.py
index c5c5325..f40a72d 100644
--- a/tests/functional/secret/test_scan_prereceive.py
+++ b/tests/functional/secret/test_scan_prereceive.py
@@ -30,13 +30,13 @@ def test_scan_prereceive(tmp_path: Path) -> None:
     secret_file = local_repo.path / "secret.conf"
     secret_content = f"password = {GG_VALID_TOKEN}"
     secret_file.write_text(secret_content)
-    local_repo.git("add", "secret.conf")
+    local_repo.add("secret.conf")
     local_repo.create_commit()
 
     # WHEN I try to push
     # THEN the hook prevents the push
     with pytest.raises(CalledProcessError) as exc:
-        local_repo.git("push")
+        local_repo.push()
 
     # AND the error message contains the leaked secret
     stderr = exc.value.stderr.decode()
@@ -53,7 +53,7 @@ def test_scan_prereceive_branch_without_new_commits(tmp_path: Path) -> None:
     # Add a commit to the remote repository, otherwise git complains the branch does not
     # contain anything
     local_repo.create_commit()
-    local_repo.git("push")
+    local_repo.push()
 
     # AND ggshield installed as a pre-receive hook
     hook_path = remote_repo.path / "hooks" / "pre-receive"
@@ -66,4 +66,37 @@ def test_scan_prereceive_branch_without_new_commits(tmp_path: Path) -> None:
 
     # WHEN I try to push the branch
     # THEN the hook does not crash
-    local_repo.git("push", "-u", "origin", branch_name)
+    local_repo.push("-u", "origin", branch_name)
+
+
+def test_scan_prereceive_push_force(tmp_path: Path) -> None:
+    # GIVEN a remote repository
+    remote_repo = Repository.create(tmp_path / "remote", bare=True)
+
+    # AND a local clone
+    local_repo = Repository.clone(remote_repo.path, tmp_path / "local")
+    initial_sha = local_repo.create_commit("Initial commit")
+
+    # AND a secret committed and pushed
+    secret_file = local_repo.path / "secret.conf"
+    secret_content = f"password = {GG_VALID_TOKEN}"
+    secret_file.write_text(secret_content)
+    local_repo.add("secret.conf")
+    local_repo.create_commit()
+    local_repo.push()
+
+    # AND ggshield installed as a pre-receive hook
+    hook_path = remote_repo.path / "hooks" / "pre-receive"
+    hook_path.write_text(HOOK_CONTENT)
+    hook_path.chmod(0o755)
+
+    # AND a commit overwriting the leak commit
+    local_repo.git("reset", "--hard", initial_sha)
+    secret_file.write_text("password = $FROM_ENV")
+    local_repo.add("secret.conf")
+    local_repo.create_commit()
+
+    # WHEN I push force to overwrite the commit with the secret
+    # THEN the push is accepted because the commit containing the secret has not been
+    # scanned
+    local_repo.push("--force")
diff --git a/tests/functional/secret/test_scan_repo.py b/tests/functional/secret/test_scan_repo.py
index 543548d..9679a33 100644
--- a/tests/functional/secret/test_scan_repo.py
+++ b/tests/functional/secret/test_scan_repo.py
@@ -13,7 +13,7 @@ def test_scan_repo(tmp_path: Path) -> None:
     secret_file = repo.path / "secret.conf"
     leak_content = f"password = {GG_VALID_TOKEN}"
     secret_file.write_text(leak_content)
-    repo.git("add", "secret.conf")
+    repo.add("secret.conf")
 
     # AND some clean commits on top of it
     for _ in range(3):
diff --git a/tests/repository.py b/tests/repository.py
index 50f85e4..2bb76ec 100644
--- a/tests/repository.py
+++ b/tests/repository.py
@@ -40,6 +40,12 @@ class Repository:
         git(["clone", str(url), str(path)])
         return cls(path, remote_url=str(url))
 
+    def add(self, *args: Union[str, Path]):
+        self.git("add", *args)
+
+    def push(self, *args: str) -> None:
+        self.git("push", *args)
+
     def create_commit(self, message: str = "Test commit") -> str:
         self._ensure_credentials_are_set()
         self.git("commit", "--allow-empty", "-m", message)
diff --git a/tests/unit/cmd/scan/test_prereceive.py b/tests/unit/cmd/scan/test_prereceive.py
index 019c46b..ea8b478 100644
--- a/tests/unit/cmd/scan/test_prereceive.py
+++ b/tests/unit/cmd/scan/test_prereceive.py
@@ -1,4 +1,3 @@
-import os
 import time
 from unittest.mock import ANY, Mock, patch
 
@@ -7,7 +6,7 @@ from click.testing import CliRunner
 from ggshield.cmd.main import cli
 from ggshield.core.errors import ExitCode
 from ggshield.core.utils import EMPTY_SHA, Filemode
-from ggshield.scan import Commit, Result, Results, ScanCollection
+from ggshield.scan import Result, Results, ScanCollection
 from ggshield.scan.repo import cd
 from ggshield.scan.scannable import File
 from tests.repository import Repository
@@ -26,56 +25,68 @@ def contains_secret(line: str, secret: str) -> bool:
     return f'"{secret[:6]}' in line and f'{secret[-6:]}"' in line
 
 
+def create_pre_receive_repo(tmp_path) -> Repository:
+    repo = Repository.create(tmp_path)
+    repo.create_commit("initial commit")
+
+    # Detach from the current branch to simulate what happens when pre-receive
+    # is called: the new commits are not in any branch yet.
+    repo.git("checkout", "--detach")
+    return repo
+
+
 class TestPreReceive:
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
     def test_stdin_input(
         self,
         scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
+        tmp_path,
         cli_fs_runner: CliRunner,
     ):
         """
-        GIVEN 20 commits through stdin input
+        GIVEN 3 commits through stdin input
         WHEN the command is run
         THEN it should pass onto scan and return 0
         """
-        scan_commit_range_mock.return_value = 0
-        get_list_mock.return_value = ["a" for _ in range(20)]
+        scan_commit_range_mock.return_value = ExitCode.SUCCESS
 
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input="bbbb aaaa origin/main\\n",
-        )
+        repo = create_pre_receive_repo(tmp_path)
+        old_sha = repo.get_top_sha()
+        shas = [repo.create_commit() for _ in range(3)]
+        with cd(repo.path):
+            result = cli_fs_runner.invoke(
+                cli,
+                ["-v", "secret", "scan", "pre-receive"],
+                input=f"{old_sha} {shas[-1]} origin/main\\n",
+            )
         assert_invoke_ok(result)
-        get_list_mock.assert_called_once_with("bbbb...aaaa", max_count=51)
         scan_commit_range_mock.assert_called_once()
-        assert "Commits to scan: 20" in result.output
+        assert "Commits to scan: 3" in result.output
 
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
     def test_stdin_input_secret(
         self,
         scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
+        tmp_path,
         cli_fs_runner: CliRunner,
     ):
         """
-        GIVEN 20 commits through stdin input
+        GIVEN 3 commits through stdin input
         WHEN the command is run and there are secrets
         THEN it should return a special remediation message
         """
         scan_commit_range_mock.return_value = ExitCode.SCAN_FOUND_PROBLEMS
-        get_list_mock.return_value = ["a" for _ in range(20)]
 
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input="bbbb aaaa origin/main\\n",
-        )
+        repo = create_pre_receive_repo(tmp_path)
+        old_sha = repo.get_top_sha()
+        shas = [repo.create_commit() for _ in range(3)]
+        with cd(repo.path):
+            result = cli_fs_runner.invoke(
+                cli,
+                ["-v", "secret", "scan", "pre-receive"],
+                input=f"{old_sha} {shas[-1]} origin/main\\n",
+            )
         assert_invoke_exited_with(result, ExitCode.SCAN_FOUND_PROBLEMS)
-        get_list_mock.assert_called_once_with("bbbb...aaaa", max_count=51)
         scan_commit_range_mock.assert_called_once()
         assert (
             """> How to remediate
@@ -94,50 +105,18 @@ class TestPreReceive:
             in result.output
         )
 
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
-    @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
-    def test_stdin_input_no_commits(
-        self,
-        scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
-        cli_fs_runner: CliRunner,
-    ):
-        """
-        GIVEN a range through stdin input but it corresponds to no commits
-        WHEN the command is run
-        THEN it should warn no commits were found and return 0
-        """
-        scan_commit_range_mock.return_value = 0
-        get_list_mock.return_value = []
-
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input="bbbb aaaa origin/main\\n",
-        )
-        assert_invoke_ok(result)
-        get_list_mock.assert_called_once_with("bbbb...aaaa", max_count=51)
-        scan_commit_range_mock.assert_not_called()
-        assert (
-            "Unable to get commit range.\\n  before: bbbb\\n  after: aaaa\\nSkipping pre-receive hook\\n\\n"
-            in result.output
-        )
-
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
     def test_stdin_breakglass_2ndoption(
         self,
         scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
         cli_fs_runner: CliRunner,
     ):
         """
-        GIVEN 20 commits through stdin input but breakglass active
+        GIVEN commits sent through stdin but breakglass active
         WHEN the command is run
         THEN it should return 0
+        AND no commits should be scanned
         """
-        get_list_mock.return_value = ["a" for _ in range(20)]
-
         result = cli_fs_runner.invoke(
             cli,
             ["-v", "secret", "scan", "pre-receive"],
@@ -149,21 +128,17 @@ class TestPreReceive:
             },
         )
         assert_invoke_ok(result)
-        get_list_mock.assert_not_called()
         scan_commit_range_mock.assert_not_called()
         assert (
             "SKIP: breakglass detected. Skipping GitGuardian pre-receive hook.\\n"
             in result.output
         )
 
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
-    @patch("ggshield.scan.repo.get_commits_by_batch")
     @patch("ggshield.scan.repo.scan_commits_content")
     def test_stdin_supports_gitlab_web_ui(
         self,
         scan_commits_content_mock: Mock,
-        get_commits_by_batch_mock: Mock,
-        get_list_mock: Mock,
+        tmp_path,
         cli_fs_runner: CliRunner,
     ):
         """
@@ -173,16 +148,22 @@ class TestPreReceive:
         AND the GL-HOOK-ERR line should be there
         AND it should contain an obfuscated version of the secret
         """
-        old_sha = "56781234"
-        new_sha = "1234abcd"
-        get_list_mock.return_value = [new_sha]
-        get_commits_by_batch_mock.return_value = [[Commit(sha=new_sha)]]
+        repo = create_pre_receive_repo(tmp_path)
+        old_sha = repo.get_top_sha()
+        secret_file = repo.path / "server.conf"
+        secret_file.write_text(f"github_token = {_SIMPLE_SECRET_TOKEN}\\n")
+        repo.add(secret_file)
+        secret_sha = repo.create_commit()
+
+        # This test cannot mock scan_commit_range(): if it did that we would not get
+        # the GitLab-specific output because output_handler.process_scan() would not be
+        # called.
         scan_commits_content_mock.return_value = ScanCollection(
             id="some_id",
             type="commit-ranges",
             scans=[
                 ScanCollection(
-                    new_sha,
+                    secret_sha,
                     type="commit",
                     results=Results(
                         results=[
@@ -199,16 +180,16 @@ class TestPreReceive:
             ],
         )
 
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input=f"{old_sha} {new_sha} origin/main\\n",
-            env={
-                "GL_PROTOCOL": "web",
-            },
-        )
+        with cd(repo.path):
+            result = cli_fs_runner.invoke(
+                cli,
+                ["-v", "secret", "scan", "pre-receive"],
+                input=f"{old_sha} {secret_sha} origin/main\\n",
+                env={
+                    "GL_PROTOCOL": "web",
+                },
+            )
         assert_invoke_exited_with(result, ExitCode.SCAN_FOUND_PROBLEMS)
-        get_list_mock.assert_called_once_with(f"{old_sha}...{new_sha}", max_count=51)
         scan_commits_content_mock.assert_called_once()
         web_ui_lines = [
             x for x in result.output.splitlines() if x.startswith("GL-HOOK-ERR: ")
@@ -232,36 +213,35 @@ class TestPreReceive:
         assert_invoke_exited_with(result, ExitCode.UNEXPECTED_ERROR)
         assert "Error: Invalid input arguments: ''\\n" in result.output
 
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
     def test_changing_max_commit_hooks(
         self,
         scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
+        tmp_path,
         cli_fs_runner: CliRunner,
     ):
         """
         GIVEN a ref creation event
         WHEN the command is run with a changed env variable for max commit hooks
-        THEN it should scan the last 20 commits
+        THEN it should scan the last 2 commits
         """
-        old_sha = "56781234"
-        new_sha = "1234abcd"
+        repo = create_pre_receive_repo(tmp_path)
+        old_sha = repo.get_top_sha()
+        shas = [repo.create_commit() for _ in range(3)]
 
-        scan_commit_range_mock.return_value = 0
-        get_list_mock.return_value = ["a" for _ in range(40)]
+        scan_commit_range_mock.return_value = ExitCode.SUCCESS
 
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input=f"{old_sha} {new_sha} main",
-            env={"GITGUARDIAN_MAX_COMMITS_FOR_HOOK": "20"},
-        )
+        with cd(repo.path):
+            result = cli_fs_runner.invoke(
+                cli,
+                ["-v", "secret", "scan", "pre-receive"],
+                input=f"{old_sha} {shas[-1]} main",
+                env={"GITGUARDIAN_MAX_COMMITS_FOR_HOOK": "2"},
+            )
 
         assert_invoke_ok(result)
-        assert "Too many commits. Scanning last 20 commits" in result.output
-        assert "Commits to scan: 20" in result.output
-        get_list_mock.assert_called_once_with(f"{old_sha}...{new_sha}", max_count=21)
+        assert "Too many commits. Scanning last 2 commits" in result.output
+        assert "Commits to scan: 2" in result.output
         scan_commit_range_mock.assert_called_once()
 
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
@@ -354,43 +334,11 @@ class TestPreReceive:
         assert_invoke_ok(result)
         assert "Deletion event or nothing to scan.\\n" in result.output
 
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
-    @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
-    def test_stdin_input_no_newline(
-        self,
-        scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
-        cli_fs_runner: CliRunner,
-    ):
-        """
-        GIVEN 20 commits through stdin input
-        WHEN the command is run
-        THEN it should pass onto scan and return 0
-        """
-        scan_commit_range_mock.return_value = 0
-        get_list_mock.return_value = ["a" for _ in range(20)]
-        old_sha = "649061dcda8bff94e02adbaac70ca64cfb84bc78"
-        new_sha = "bfffbd925b1ce9298e6c56eb525b8d7211603c09"
-
-        result = cli_fs_runner.invoke(
-            cli,
-            ["-v", "secret", "scan", "pre-receive"],
-            input=f"{old_sha} {new_sha} refs/heads/main",
-        )
-        assert_invoke_ok(result)
-        get_list_mock.assert_called_once_with(
-            f"{old_sha}...{new_sha}",
-            max_count=51,
-        )
-        scan_commit_range_mock.assert_called_once()
-        assert "Commits to scan: 20" in result.output
-
-    @patch("ggshield.cmd.secret.scan.prereceive.get_list_commit_SHA")
     @patch("ggshield.cmd.secret.scan.prereceive.scan_commit_range")
     def test_timeout(
         self,
         scan_commit_range_mock: Mock,
-        get_list_mock: Mock,
+        tmp_path,
         cli_fs_runner: CliRunner,
     ):
         """
@@ -408,15 +356,18 @@ class TestPreReceive:
                 time.sleep(0.05)
 
         scan_commit_range_mock.side_effect = sleepy_scan
-        scan_commit_range_mock.return_value = 2
-        get_list_mock.return_value = ["a" for _ in range(20)]
+        scan_commit_range_mock.return_value = ExitCode.UNEXPECTED_ERROR
+        repo = create_pre_receive_repo(tmp_path)
+        old_sha = repo.get_top_sha()
+        new_sha = repo.create_commit()
 
         start = time.time()
-        with patch.dict(os.environ, {"GITGUARDIAN_TIMEOUT": str(scan_timeout)}):
+        with cd(repo.path):
             result = cli_fs_runner.invoke(
                 cli,
                 ["-v", "secret", "scan", "pre-receive"],
-                input="bbbb aaaa origin/main\\n",
+                input=f"{old_sha} {new_sha} origin/main\\n",
+                env={"GITGUARDIAN_TIMEOUT": str(scan_timeout)},
             )
         duration = time.time() - start
         assert_invoke_ok(result)