Repository: gitguardian/ggshield
Issue: `ggshield secret scan repo` aborts in some case instead of continuing

## GitGuardian Shield Version

1.13.0

## Command executed

`ggshield secret scan repo .`

## Description

Even with the changes from #267, `ggshield secret scan repo` can still abort. This can happen when the failure happens *before* scanning. This happens with #322.

## Possible fix

Catch all exceptions, report them and carry on

Changed files:
ggshield/scan/scannable.py, ggshield/scan/repo.py

diff --git a/ggshield/scan/repo.py b/ggshield/scan/repo.py
index 5937be8..ac2c3e1 100644
--- a/ggshield/scan/repo.py
+++ b/ggshield/scan/repo.py
@@ -12,11 +12,11 @@ from ggshield.core.cache import Cache
 from ggshield.core.config import Config
 from ggshield.core.constants import CPU_COUNT
 from ggshield.core.git_shell import get_list_commit_SHA, is_git_dir
-from ggshield.core.text_utils import STYLE, format_text
+from ggshield.core.text_utils import STYLE, display_error, format_text
 from ggshield.core.types import IgnoredMatch
 from ggshield.core.utils import SupportedScanMode, handle_exception
 from ggshield.output import OutputHandler
-from ggshield.scan import Commit, ScanCollection
+from ggshield.scan import Commit, Results, ScanCollection
 
 
 @contextmanager
@@ -65,13 +65,16 @@ def scan_commit(
     matches_ignore: Iterable[IgnoredMatch],
     ignored_detectors: Optional[Set[str]] = None,
 ) -> ScanCollection:  # pragma: no cover
-    results = commit.scan(
-        client=client,
-        cache=cache,
-        matches_ignore=matches_ignore,
-        mode_header=SupportedScanMode.REPO.value,
-        ignored_detectors=ignored_detectors,
-    )
+    try:
+        results = commit.scan(
+            client=client,
+            cache=cache,
+            matches_ignore=matches_ignore,
+            mode_header=SupportedScanMode.REPO.value,
+            ignored_detectors=ignored_detectors,
+        )
+    except Exception as exc:
+        results = Results.from_exception(exc)
 
     return ScanCollection(
         commit.sha or "unknown",
@@ -125,7 +128,12 @@ def scan_commit_range(
             file=sys.stderr,
         ) as completed_futures:
             for future in completed_futures:
-                scans.append(future.result())
+                scan_collection = future.result()
+                if scan_collection.results and scan_collection.results.errors:
+                    for error in scan_collection.results.errors:
+                        # Prefix with `\\n` because we are in the middle of a progress bar
+                        display_error("\\n{error.description}")
+                scans.append(scan_collection)
 
         return_code = output_handler.process_scan(
             ScanCollection(id=scan_id, type="commit-range", scans=scans)
diff --git a/ggshield/scan/scannable.py b/ggshield/scan/scannable.py
index d155cf1..6dc5e69 100644
--- a/ggshield/scan/scannable.py
+++ b/ggshield/scan/scannable.py
@@ -1,6 +1,7 @@
 import concurrent.futures
 import logging
 import re
+from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set, Tuple
 
@@ -58,14 +59,26 @@ class Error(NamedTuple):
     description: str  # Description of the error
 
 
-class Results(NamedTuple):
+@dataclass(frozen=True)
+class Results:
     """
     Return model for a scan with the results and errors of the scan
+
+    Not a NamedTuple like the others because it causes mypy 0.961 to crash on the
+    `from_exception()` method (!)
+
+    Similar crash: https://github.com/python/mypy/issues/12629
     """
 
     results: List[Result]
     errors: List[Error]
 
+    @staticmethod
+    def from_exception(exc: Exception) -> "Results":
+        """Create a Results representing a failure"""
+        error = Error(files=[], description=str(exc))
+        return Results(results=[], errors=[error])
+
 
 class ScanCollection(NamedTuple):
     id: str