Repository: gitguardian/ggshield
Issue: All Clear Message Missing in 1.14

## Environment

- ggshield version: 1.14.2
- Operating system (Linux, macOS, Windows): macOS
- Operating system version: 12.6
- Python version: 3.10.8

## Describe the bug

1. Configure GGShield as pre-commit hook
2. Execute a `git commit` that does not include a secret

Actual result:

No output from GGShield indicating a clean scan

Expected result:

In previous versions the following message would be displayed:

> No secrets have been found


Changed files:
tests/unit/cmd/scan/test_path.py, ggshield/output/text/message.py, tests/unit/output/test_text_output.py, ggshield/output/text/text_output_handler.py

diff --git a/ggshield/output/text/message.py b/ggshield/output/text/message.py
index 11cb755..de6eac2 100644
--- a/ggshield/output/text/message.py
+++ b/ggshield/output/text/message.py
@@ -238,6 +238,20 @@ def policy_break_header(
 """
 
 
+def no_leak_message() -> str:
+    """
+    Build a message if no secret is found.
+    """
+    return format_text("\\nNo secrets have been found\\n", STYLE["no_secret"])
+
+
+def no_new_leak_message() -> str:
+    """
+    Build a message if no new secret is found.
+    """
+    return format_text("\\nNo new secrets have been found\\n", STYLE["no_secret"])
+
+
 def iac_vulnerability_header(issue_n: int, vulnerability: IaCVulnerability) -> str:
     """
     Build a header for the iac policy break.
diff --git a/ggshield/output/text/text_output_handler.py b/ggshield/output/text/text_output_handler.py
index cd08b6c..0df1b17 100644
--- a/ggshield/output/text/text_output_handler.py
+++ b/ggshield/output/text/text_output_handler.py
@@ -15,6 +15,8 @@ from .message import (
     file_info,
     flatten_policy_breaks_by_line,
     leak_message_located,
+    no_leak_message,
+    no_new_leak_message,
     policy_break_header,
     secrets_engine_version,
 )
@@ -25,12 +27,16 @@ class TextOutputHandler(OutputHandler):
     nb_lines: ClassVar[int] = 3
 
     def _process_scan_impl(self, scan: ScanCollection, top: bool = True) -> str:
-        scan_buf = StringIO()
+        processed_scan_results = self.process_scan_results(scan)
 
+        scan_buf = StringIO()
         if self.verbose:
             scan_buf.write(secrets_engine_version())
-
-        scan_buf.write(self.process_scan_results(scan))
+        scan_buf.write(processed_scan_results)
+        if not processed_scan_results:
+            scan_buf.write(
+                no_new_leak_message() if scan.known_secrets_count else no_leak_message()
+            )
 
         if scan.known_secrets_count > 0:
             scan_buf.write(
diff --git a/tests/unit/cmd/scan/test_path.py b/tests/unit/cmd/scan/test_path.py
index d94f579..ad0ee8e 100644
--- a/tests/unit/cmd/scan/test_path.py
+++ b/tests/unit/cmd/scan/test_path.py
@@ -46,6 +46,7 @@ class TestPathScan:
             result = cli_fs_runner.invoke(cli, ["secret", "scan", "path", "file"])
         assert result.exit_code == ExitCode.SUCCESS, result.output
         assert not result.exception
+        assert "No secrets have been found" in result.output
 
     @pytest.mark.parametrize("use_deprecated_syntax", [False, True])
     def test_scan_file_secret(self, cli_fs_runner, use_deprecated_syntax):
@@ -173,6 +174,7 @@ class TestPathScan:
         assert not result.exception
         assert "file1\\n" in result.output
         assert "file2\\n" in result.output
+        assert "No secrets have been found" in result.output
 
     def test_files_verbose_abort(self, cli_fs_runner):
         self.create_files()
@@ -192,6 +194,7 @@ class TestPathScan:
         assert not result.exception
         assert "file1\\n" in result.output
         assert "file2\\n" in result.output
+        assert "No secrets have been found" in result.output
 
 
 class TestScanDirectory:
@@ -243,6 +246,7 @@ class TestScanDirectory:
         assert "file1\\n" in result.output
         assert self.path_line("dir/file2") in result.output
         assert self.path_line("dir/subdir/file3") in result.output
+        assert "No secrets have been found" in result.output
 
     def test_directory_verbose_abort(self, cli_fs_runner):
         self.create_files()
@@ -288,6 +292,7 @@ class TestScanDirectory:
         assert "file1\\n" in result.output
         assert self.path_line("dir/file2") in result.output
         assert self.path_line("dir/subdir/file3") in result.output
+        assert "No secrets have been found" in result.output
 
     def test_scan_path_should_detect_non_git_files(self, cli_fs_runner):
         """
@@ -354,3 +359,5 @@ class TestScanDirectory:
                 assert (
                     f": {nb_secret} incident{'s' if plural else ''} "
                 ) in result.output
+            else:
+                assert "No secrets have been found" in result.output
diff --git a/tests/unit/output/test_text_output.py b/tests/unit/output/test_text_output.py
index c4e3d62..5ce9315 100644
--- a/tests/unit/output/test_text_output.py
+++ b/tests/unit/output/test_text_output.py
@@ -138,39 +138,74 @@ def test_leak_message(result_input, snapshot, show_secrets, verbose):
     snapshot.assert_match(output)
 
 
-def assert_policies_displayed(output, policy_breaks):
+def assert_policies_displayed(output, verbose, ignore_known_secrets, policy_breaks):
+    nb_new_secrets = 0
     for policy_break in policy_breaks:
-        if policy_break.known_secret:
-            assert f"Known secret: {policy_break.break_type}" in output
-        else:
+        if not ignore_known_secrets:
+            # All secrets are displayed no matter if they're known or not
+            nb_new_secrets += 1
             assert f"Secret detected: {policy_break.break_type}" in output
+        else:
+            if verbose:
+                # Known secrets are still displayed with a dedicated message
+                if policy_break.known_secret:
+                    assert f"Known secret: {policy_break.break_type}" in output
+                else:
+                    nb_new_secrets += 1
+                    assert f"Secret detected: {policy_break.break_type}" in output
+            else:
+                # Only new secrets are displayed
+                if not policy_break.known_secret:
+                    nb_new_secrets += 1
+                    assert f"Secret detected: {policy_break.break_type}" in output
+    if nb_new_secrets:
+        assert (
+            f"{nb_new_secrets} incident{'s' if nb_new_secrets > 1 else ''} detected"
+            in output
+        )
 
 
-def assert_warning_is_displayed(output, warning_is_displayed, known_secrets_number):
-    if warning_is_displayed:
+def assert_warning_is_displayed(
+    output: str,
+    ignore_known_secrets: bool,
+    secrets_types: str,
+    known_secrets_number: int,
+):
+    if ignore_known_secrets and secrets_types in {
+        "only_known_secrets",
+        "mixed_secrets",
+    }:
         plural = (
             "s ignored because they are"
             if known_secrets_number > 1
             else " ignored because it is"
         )
         assert (
-            f"Warning: {known_secrets_number} secret{plural} already known by your GitGuardian"
-            f" dashboard and you used the `--ignore-known-secrets` option." in output
+            f"Warning: {known_secrets_number} secret{plural} already known by "
+            "your GitGuardian dashboard and you used the "
+            "`--ignore-known-secrets` option." in output
         )
 
 
-def assert_number_of_secrets_is_displayed(output, nb_new_secrets):
-    if nb_new_secrets:
-        assert (
-            f"{nb_new_secrets} incident{'s' if nb_new_secrets > 1 else ''} detected"
-            in output
-        )
+def assert_no_leak_message_is_diplayed(
+    output: str, ignore_known_secrets: bool, secrets_types: str
+):
+    if secrets_types == "no_secrets":
+        assert "No secrets have been found" in output
+        assert "No new secrets have been found" not in output
+    elif ignore_known_secrets and secrets_types == "only_known_secrets":
+        assert "No new secrets have been found" in output
+        assert "No secrets have been found" not in output
+    else:
+        assert "No secrets have been found" not in output
+        assert "No new secrets have been found" not in output
 
 
 @pytest.mark.parametrize("verbose", [True, False])
 @pytest.mark.parametrize("ignore_known_secrets", [True, False])
 @pytest.mark.parametrize(
-    "secrets_types", ["only_new_secrets", "only_known_secrets", "mixed_secrets"]
+    "secrets_types",
+    ["only_new_secrets", "only_known_secrets", "mixed_secrets", "no_secrets"],
 )
 def test_ignore_known_secrets(verbose, ignore_known_secrets, secrets_types):
     """
@@ -190,7 +225,10 @@ def test_ignore_known_secrets(verbose, ignore_known_secrets, secrets_types):
     known_policy_breaks = []
     new_policy_breaks = all_policy_breaks
 
-    # add known_secret for the secrets that are known, when the option is, the known_secret field is not returned
+    if secrets_types == "no_secrets":
+        known_policy_breaks = []
+        new_policy_breaks = []
+
     if ignore_known_secrets:
         if secrets_types == "only_known_secrets":
             known_policy_breaks = all_policy_breaks
@@ -213,39 +251,27 @@ def test_ignore_known_secrets(verbose, ignore_known_secrets, secrets_types):
                 ScanCollection(
                     id="scan",
                     type="test",
-                    results=Results(results=[result], errors=[]),
+                    results=Results(
+                        results=[result] if secrets_types != "no_secrets" else [],
+                        errors=[],
+                    ),
                     optional_header="> This is an example header",
                 )
             ],
         )
     )
 
-    if secrets_types == "only_new_secrets" or not ignore_known_secrets:
-        expected_policies_break_displayed = all_policy_breaks
-        expected_warning_is_displayed = False
-    else:
-        expected_warning_is_displayed = True
-
-        if verbose:
-            expected_policies_break_displayed = known_policy_breaks
-        else:
-            if secrets_types == "only_known_secrets":
-                # Do not show secrets if there are only known secrets and not in verbose mode
-                expected_policies_break_displayed = []
-            else:
-                expected_policies_break_displayed = new_policy_breaks
-
     output = click.unstyle(output).replace(
         _file_info_decoration(), _file_info_default_decoration()
     )
 
-    assert_policies_displayed(output, expected_policies_break_displayed)
-    assert_warning_is_displayed(
-        output, expected_warning_is_displayed, len(known_policy_breaks)
+    assert_policies_displayed(
+        output, verbose, ignore_known_secrets, known_policy_breaks + new_policy_breaks
     )
-    assert_number_of_secrets_is_displayed(
-        output, len(expected_policies_break_displayed)
+    assert_warning_is_displayed(
+        output, ignore_known_secrets, secrets_types, len(known_policy_breaks)
     )
+    assert_no_leak_message_is_diplayed(output, ignore_known_secrets, secrets_types)
 
 
 @pytest.mark.parametrize("ignore_known_secrets", [True, False])